Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
5b70de43dc chore: bump version to 0.7.5 2026-04-22 21:22:39 +00:00
21 changed files with 972 additions and 3254 deletions

View File

@@ -264,13 +264,13 @@ The base classes handle most work automatically. Override only when the agent de
| Override | When to use | Example | | Override | When to use | Example |
|---|---|---| |---|---|---|
| `command_filename(template_name)` | Custom file naming or extension | Copilot → `speckit.{name}.agent.md` | | `command_filename(template_name)` | Custom file naming or extension | Copilot → `speckit.{name}.agent.md` |
| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag, Copilot → `--skills` flag | | `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag |
| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` (default) or `speckit-<name>/SKILL.md` (skills mode) | | `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` |
| `teardown()` | Custom uninstall logic | Rarely needed; base handles manifest-tracked files | | `teardown()` | Custom uninstall logic | Rarely needed; base handles manifest-tracked files |
**Example — Copilot (fully custom `setup`):** **Example — Copilot (fully custom `setup`):**
Copilot extends `IntegrationBase` directly because it creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. It also supports a `--skills` mode that scaffolds `speckit-<name>/SKILL.md` under `.github/skills/` using composition with an internal `_CopilotSkillsHelper`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation. Copilot extends `IntegrationBase` directly because it creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation.
### 7. Update Devcontainer files (Optional) ### 7. Update Devcontainer files (Optional)
@@ -391,24 +391,6 @@ Implementation: Extends `IntegrationBase` with custom `setup()` method that:
2. Generates companion `.prompt.md` files 2. Generates companion `.prompt.md` files
3. Merges VS Code settings 3. Merges VS Code settings
**Skills mode (`--skills`):** Copilot also supports an alternative skills-based layout
via `--integration-options="--skills"`. When enabled:
- Commands are scaffolded as `speckit-<name>/SKILL.md` under `.github/skills/`
- No companion `.prompt.md` files are generated
- No `.vscode/settings.json` merge
- `post_process_skill_content()` injects a `mode: speckit.<stem>` frontmatter field
- `build_command_invocation()` returns `/speckit-<stem>` instead of bare args
The two modes are mutually exclusive — a project uses one or the other:
```bash
# Default mode: .agent.md agents + .prompt.md companions + settings merge
specify init my-project --integration copilot
# Skills mode: speckit-<name>/SKILL.md under .github/skills/
specify init my-project --integration copilot --integration-options="--skills"
```
### Forge Integration ### Forge Integration
Forge has special frontmatter and argument requirements: Forge has special frontmatter and argument requirements:

View File

@@ -2,18 +2,6 @@
<!-- insert new changelog below this comment --> <!-- insert new changelog below this comment -->
## [0.8.0] - 2026-04-23
### Changed
- feat(presets): Composition strategies (prepend, append, wrap) for templates, commands, and scripts (#2133)
- feat(copilot): support `--integration-options="--skills"` for skills-based scaffolding (#2324)
- docs(install): add pipx as alternative installation method (#2288)
- Add Memory MD community extension (#2327)
- Update version-guard to v1.2.0 (#2321)
- fix: `--force` now overwrites shared infra files during init and upgrade (#2320)
- chore: release 0.7.5, begin 0.7.6.dev0 development (#2322)
## [0.7.5] - 2026-04-22 ## [0.7.5] - 2026-04-22
### Changed ### Changed

View File

@@ -62,10 +62,6 @@ uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX
# Or install latest from main (may include unreleased changes) # Or install latest from main (may include unreleased changes)
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git uv tool install specify-cli --from git+https://github.com/github/spec-kit.git
# Alternative: using pipx (also works)
pipx install git+https://github.com/github/spec-kit.git@vX.Y.Z
pipx install git+https://github.com/github/spec-kit.git
``` ```
Then verify the correct version is installed: Then verify the correct version is installed:
@@ -93,7 +89,6 @@ To upgrade Specify, see the [Upgrade Guide](./docs/upgrade.md) for detailed inst
```bash ```bash
uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z
# pipx users: pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z
``` ```
#### Option 2: One-time Usage #### Option 2: One-time Usage
@@ -229,7 +224,6 @@ The following community-contributed extensions are available in [`catalog.commun
| MAQA Linear Integration | Linear integration for MAQA — syncs issues and sub-issues across workflow states as features progress | `integration` | Read+Write | [spec-kit-maqa-linear](https://github.com/GenieRobot/spec-kit-maqa-linear) | | MAQA Linear Integration | Linear integration for MAQA — syncs issues and sub-issues across workflow states as features progress | `integration` | Read+Write | [spec-kit-maqa-linear](https://github.com/GenieRobot/spec-kit-maqa-linear) |
| MAQA Trello Integration | Trello board integration for MAQA — populates board from specs, moves cards, real-time checklist ticking | `integration` | Read+Write | [spec-kit-maqa-trello](https://github.com/GenieRobot/spec-kit-maqa-trello) | | MAQA Trello Integration | Trello board integration for MAQA — populates board from specs, moves cards, real-time checklist ticking | `integration` | Read+Write | [spec-kit-maqa-trello](https://github.com/GenieRobot/spec-kit-maqa-trello) |
| Memory Loader | Loads .specify/memory/ files before lifecycle commands so LLM agents have project governance context | `docs` | Read-only | [spec-kit-memory-loader](https://github.com/KevinBrown5280/spec-kit-memory-loader) | | Memory Loader | Loads .specify/memory/ files before lifecycle commands so LLM agents have project governance context | `docs` | Read-only | [spec-kit-memory-loader](https://github.com/KevinBrown5280/spec-kit-memory-loader) |
| Memory MD | Repository-native durable memory for Spec Kit projects | `docs` | Read+Write | [spec-kit-memory-hub](https://github.com/DyanGalih/spec-kit-memory-hub) |
| MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) | | MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) |
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) | | Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) | | Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
@@ -431,7 +425,7 @@ Our research and experimentation focus on:
- **Linux/macOS/Windows** - **Linux/macOS/Windows**
- [Supported](#-supported-ai-coding-agent-integrations) AI coding agent. - [Supported](#-supported-ai-coding-agent-integrations) AI coding agent.
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pypa.github.io/pipx/) for persistent installation - [uv](https://docs.astral.sh/uv/) for package management
- [Python 3.11+](https://www.python.org/downloads/) - [Python 3.11+](https://www.python.org/downloads/)
- [Git](https://git-scm.com/downloads) - [Git](https://git-scm.com/downloads)

View File

@@ -4,7 +4,7 @@
- **Linux/macOS** (or Windows; PowerShell scripts now supported without WSL) - **Linux/macOS** (or Windows; PowerShell scripts now supported without WSL)
- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev) - AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev)
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pypa.github.io/pipx/) for persistent installation - [uv](https://docs.astral.sh/uv/) for package management
- [Python 3.11+](https://www.python.org/downloads/) - [Python 3.11+](https://www.python.org/downloads/)
- [Git](https://git-scm.com/downloads) - [Git](https://git-scm.com/downloads)
@@ -24,13 +24,6 @@ uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <PROJE
uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME> uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME>
``` ```
> [!NOTE]
> For a persistent installation, `pipx` works equally well:
> ```bash
> pipx install git+https://github.com/github/spec-kit.git@vX.Y.Z
> ```
> The project uses a standard `hatchling` build backend and has no uv-specific dependencies.
Or initialize in the current directory: Or initialize in the current directory:
```bash ```bash

View File

@@ -22,17 +22,6 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME
uvx --from git+https://github.com/github/spec-kit.git specify init . uvx --from git+https://github.com/github/spec-kit.git specify init .
``` ```
> [!NOTE]
> You can also install the CLI persistently with `pipx`:
> ```bash
> pipx install git+https://github.com/github/spec-kit.git
> ```
> After installing with `pipx`, run `specify` directly instead of `uvx --from ... specify`, for example:
> ```bash
> specify init <PROJECT_NAME>
> specify init .
> ```
Pick script type explicitly (optional): Pick script type explicitly (optional):
```bash ```bash

View File

@@ -9,7 +9,6 @@
| What to Upgrade | Command | When to Use | | What to Upgrade | Command | When to Use |
|----------------|---------|-------------| |----------------|---------|-------------|
| **CLI Tool Only** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | Get latest CLI features without touching project files | | **CLI Tool Only** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | Get latest CLI features without touching project files |
| **CLI Tool Only (pipx)** | `pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z` | Reinstall/upgrade a pipx-installed CLI to a specific release |
| **Project Files** | `specify init --here --force --ai <your-agent>` | Update slash commands, templates, and scripts in your project | | **Project Files** | `specify init --here --force --ai <your-agent>` | Update slash commands, templates, and scripts in your project |
| **Both** | Run CLI upgrade, then project update | Recommended for major version updates | | **Both** | Run CLI upgrade, then project update | Recommended for major version updates |
@@ -35,14 +34,6 @@ Specify the desired release tag:
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai copilot uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai copilot
``` ```
### If you installed with `pipx`
Upgrade to a specific release:
```bash
pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z
```
### Verify the upgrade ### Verify the upgrade
```bash ```bash
@@ -62,8 +53,8 @@ When Spec Kit releases new features (like new slash commands or updated template
Running `specify init --here --force` will update: Running `specify init --here --force` will update:
-**Slash command files** (`.claude/commands/`, `.github/prompts/`, etc.) -**Slash command files** (`.claude/commands/`, `.github/prompts/`, etc.)
-**Script files** (`.specify/scripts/`)**only with `--force`**; without it, only missing files are added -**Script files** (`.specify/scripts/`)
-**Template files** (`.specify/templates/`)**only with `--force`**; without it, only missing files are added -**Template files** (`.specify/templates/`)
-**Shared memory files** (`.specify/memory/`) - **⚠️ See warnings below** -**Shared memory files** (`.specify/memory/`) - **⚠️ See warnings below**
### What stays safe? ### What stays safe?
@@ -103,9 +94,7 @@ Template files will be merged with existing content and may overwrite existing f
Proceed? [y/N] Proceed? [y/N]
``` ```
With `--force`, it skips the confirmation and proceeds immediately. It also **overwrites shared infrastructure files** (`.specify/scripts/` and `.specify/templates/`) with the latest versions from the installed Spec Kit release. With `--force`, it skips the confirmation and proceeds immediately.
Without `--force`, shared infrastructure files that already exist are skipped — the CLI will print a warning listing the skipped files so you know which ones were not updated.
**Important: Your `specs/` directory is always safe.** The `--force` flag only affects template files (commands, scripts, templates, memory). Your feature specifications, plans, and tasks in `specs/` are never included in upgrade packages and cannot be overwritten. **Important: Your `specs/` directory is always safe.** The `--force` flag only affects template files (commands, scripts, templates, memory). Your feature specifications, plans, and tasks in `specs/` are never included in upgrade packages and cannot be overwritten.
@@ -137,14 +126,13 @@ Or use git to restore it:
git restore .specify/memory/constitution.md git restore .specify/memory/constitution.md
``` ```
### 2. Custom script or template modifications ### 2. Custom template modifications
If you customized files in `.specify/scripts/` or `.specify/templates/`, the `--force` flag will overwrite them. Back them up first: If you customized any templates in `.specify/templates/`, the upgrade will overwrite them. Back them up first:
```bash ```bash
# Back up custom templates and scripts # Back up custom templates
cp -r .specify/templates .specify/templates-backup cp -r .specify/templates .specify/templates-backup
cp -r .specify/scripts .specify/scripts-backup
# After upgrade, merge your changes back manually # After upgrade, merge your changes back manually
``` ```

View File

@@ -1,6 +1,6 @@
{ {
"schema_version": "1.0", "schema_version": "1.0",
"updated_at": "2026-04-23T00:00:00Z", "updated_at": "2026-04-22T17:54:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": { "extensions": {
"aide": { "aide": {
@@ -1198,38 +1198,6 @@
"created_at": "2026-04-20T00:00:00Z", "created_at": "2026-04-20T00:00:00Z",
"updated_at": "2026-04-20T00:00:00Z" "updated_at": "2026-04-20T00:00:00Z"
}, },
"memory-md": {
"name": "Memory MD",
"id": "memory-md",
"description": "Repository-native durable memory for Spec Kit projects",
"author": "DyanGalih",
"version": "0.6.2",
"download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.6.2.zip",
"repository": "https://github.com/DyanGalih/spec-kit-memory-hub",
"homepage": "https://github.com/DyanGalih/spec-kit-memory-hub",
"documentation": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/README.md",
"changelog": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/docs/memory-workflow-v0.6.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.6.0"
},
"provides": {
"commands": 5,
"hooks": 0
},
"tags": [
"memory",
"workflow",
"docs",
"copilot",
"markdown"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-23T00:00:00Z",
"updated_at": "2026-04-23T00:00:00Z"
},
"memorylint": { "memorylint": {
"name": "MemoryLint", "name": "MemoryLint",
"id": "memorylint", "id": "memorylint",
@@ -2383,8 +2351,8 @@
"id": "version-guard", "id": "version-guard",
"description": "Verify tech stack versions against live registries before planning and implementation", "description": "Verify tech stack versions against live registries before planning and implementation",
"author": "KevinBrown5280", "author": "KevinBrown5280",
"version": "1.2.0", "version": "1.1.0",
"download_url": "https://github.com/KevinBrown5280/spec-kit-version-guard/archive/refs/tags/v1.2.0.zip", "download_url": "https://github.com/KevinBrown5280/spec-kit-version-guard/archive/refs/tags/v1.1.0.zip",
"repository": "https://github.com/KevinBrown5280/spec-kit-version-guard", "repository": "https://github.com/KevinBrown5280/spec-kit-version-guard",
"homepage": "https://github.com/KevinBrown5280/spec-kit-version-guard", "homepage": "https://github.com/KevinBrown5280/spec-kit-version-guard",
"documentation": "https://github.com/KevinBrown5280/spec-kit-version-guard/blob/main/README.md", "documentation": "https://github.com/KevinBrown5280/spec-kit-version-guard/blob/main/README.md",
@@ -2407,7 +2375,7 @@
"downloads": 0, "downloads": 0,
"stars": 0, "stars": 0,
"created_at": "2026-04-20T00:00:00Z", "created_at": "2026-04-20T00:00:00Z",
"updated_at": "2026-04-22T21:10:00Z" "updated_at": "2026-04-22T17:54:00Z"
}, },
"whatif": { "whatif": {
"name": "What-if Analysis", "name": "What-if Analysis",
@@ -2537,4 +2505,4 @@
"updated_at": "2026-04-13T00:00:00Z" "updated_at": "2026-04-13T00:00:00Z"
} }
} }
} }

View File

@@ -41,24 +41,6 @@ The resolution is implemented three times to ensure consistency:
- **Bash**: `resolve_template()` in `scripts/bash/common.sh` - **Bash**: `resolve_template()` in `scripts/bash/common.sh`
- **PowerShell**: `Resolve-Template` in `scripts/powershell/common.ps1` - **PowerShell**: `Resolve-Template` in `scripts/powershell/common.ps1`
### Composition Strategies
Templates, commands, and scripts support a `strategy` field that controls how a preset's content is combined with lower-priority content instead of fully replacing it:
| Strategy | Description | Templates | Commands | Scripts |
|----------|-------------|-----------|----------|---------|
| `replace` (default) | Fully replaces lower-priority content | ✓ | ✓ | ✓ |
| `prepend` | Places content before lower-priority content (separated by a blank line) | ✓ | ✓ | — |
| `append` | Places content after lower-priority content (separated by a blank line) | ✓ | ✓ | — |
| `wrap` | Content contains `{CORE_TEMPLATE}` (templates/commands) or `$CORE_SCRIPT` (scripts) placeholder replaced with lower-priority content | ✓ | ✓ | ✓ |
Composition is recursive — multiple composing presets chain. The `PresetResolver.resolve_content()` method walks the full priority stack bottom-up and applies each layer's strategy.
Content resolution functions for composition:
- **Python**: `PresetResolver.resolve_content()` in `src/specify_cli/presets.py` (templates, commands, and scripts)
- **Bash**: `resolve_template_content()` in `scripts/bash/common.sh` (templates only; command/script composition is handled by the Python resolver)
- **PowerShell**: `Resolve-TemplateContent` in `scripts/powershell/common.ps1` (templates only; command/script composition is handled by the Python resolver)
## Command Registration ## Command Registration
When a preset is installed with `type: "command"` entries, the `PresetManager` registers them into all detected agent directories using the shared `CommandRegistrar` from `src/specify_cli/agents.py`. When a preset is installed with `type: "command"` entries, the `PresetManager` registers them into all detected agent directories using the shared `CommandRegistrar` from `src/specify_cli/agents.py`.

View File

@@ -61,37 +61,7 @@ specify preset add healthcare-compliance --priority 5 # overrides enterprise-sa
specify preset add pm-workflow --priority 1 # overrides everything specify preset add pm-workflow --priority 1 # overrides everything
``` ```
Presets **override by default**, they don't merge. If two presets both provide `spec-template` with the default `replace` strategy, the one with the lowest priority number wins entirely. However, presets can use **composition strategies** to augment rather than replace content. Presets **override**, they don't merge. If two presets both provide `spec-template`, the one with the lowest priority number wins entirely.
### Composition Strategies
Presets can declare a `strategy` per template to control how content is combined. The `name` field identifies which template to compose with in the priority stack, while `file` points to the actual content file (which can differ from the convention path `templates/<name>.md`):
```yaml
provides:
templates:
- type: "template"
name: "spec-template"
file: "templates/spec-addendum.md"
strategy: "append" # adds content after the core template
```
| Strategy | Description |
|----------|-------------|
| `replace` (default) | Fully replaces the lower-priority template |
| `prepend` | Places content **before** the resolved lower-priority template, separated by a blank line |
| `append` | Places content **after** the resolved lower-priority template, separated by a blank line |
| `wrap` | Content contains `{CORE_TEMPLATE}` placeholder (or `$CORE_SCRIPT` for scripts) replaced with the lower-priority content |
**Supported combinations:**
| Type | `replace` | `prepend` | `append` | `wrap` |
|------|-----------|-----------|----------|--------|
| **template** | ✓ (default) | ✓ | ✓ | ✓ |
| **command** | ✓ (default) | ✓ | ✓ | ✓ |
| **script** | ✓ (default) | — | — | ✓ |
Multiple composing presets chain recursively. For example, a security preset with `prepend` and a compliance preset with `append` will produce: security header + core content + compliance footer.
## Catalog Management ## Catalog Management
@@ -138,5 +108,13 @@ See [scaffold/](scaffold/) for a scaffold you can copy to create your own preset
The following enhancements are under consideration for future releases: The following enhancements are under consideration for future releases:
- **Structural merge strategies** — Parsing Markdown sections for per-section granularity (e.g., "replace only ## Security"). - **Composition strategies** — Allow presets to declare a `strategy` per template instead of the default `replace`:
- **Conflict detection** — `specify preset lint` / `specify preset doctor` for detecting composition conflicts.
| Type | `replace` | `prepend` | `append` | `wrap` |
|------|-----------|-----------|----------|--------|
| **template** | ✓ (default) | ✓ | ✓ | ✓ |
| **command** | ✓ (default) | ✓ | ✓ | ✓ |
| **script** | ✓ (default) | — | — | ✓ |
For artifacts and commands (which are LLM directives), `wrap` injects preset content before and after the core template using a `{CORE_TEMPLATE}` placeholder (implemented). For scripts, `wrap` would run custom logic before/after the core script via a `$CORE_SCRIPT` variable (not yet implemented).
- **Script overrides** — Enable presets to provide alternative versions of core scripts (e.g. `create-new-feature.sh`) for workflow customization. A `strategy: "wrap"` option could allow presets to run custom logic before/after the core script without fully replacing it.

View File

@@ -32,15 +32,6 @@ provides:
templates: templates:
# CUSTOMIZE: Define your template overrides # CUSTOMIZE: Define your template overrides
# Templates are document scaffolds (spec-template.md, plan-template.md, etc.) # Templates are document scaffolds (spec-template.md, plan-template.md, etc.)
#
# Strategy options (optional, defaults to "replace"):
# replace - Fully replaces the lower-priority template (default)
# prepend - Places this content BEFORE the lower-priority template
# append - Places this content AFTER the lower-priority template
# wrap - Uses {CORE_TEMPLATE} placeholder (templates/commands) or
# $CORE_SCRIPT placeholder (scripts), replaced with lower-priority content
#
# Note: Scripts only support "replace" and "wrap" strategies.
- type: "template" - type: "template"
name: "spec-template" name: "spec-template"
file: "templates/spec-template.md" file: "templates/spec-template.md"
@@ -54,26 +45,6 @@ provides:
# description: "Custom plan template" # description: "Custom plan template"
# replaces: "plan-template" # replaces: "plan-template"
# COMPOSITION EXAMPLES:
# The `file` field points to the content file (can differ from the
# convention path `templates/<name>.md`). The `name` field identifies
# which template to compose with in the priority stack.
#
# Append additional sections to an existing template:
# - type: "template"
# name: "spec-template"
# file: "templates/spec-addendum.md"
# description: "Add compliance section to spec template"
# strategy: "append"
#
# Wrap a command with preamble/sign-off:
# - type: "command"
# name: "speckit.specify"
# file: "commands/specify-wrapper.md"
# description: "Wrap specify command with compliance checks"
# strategy: "wrap"
# # In the wrapper file, use {CORE_TEMPLATE} where the original content goes
# OVERRIDE EXTENSION TEMPLATES: # OVERRIDE EXTENSION TEMPLATES:
# Presets sit above extensions in the resolution stack, so you can # Presets sit above extensions in the resolution stack, so you can
# override templates provided by any installed extension. # override templates provided by any installed extension.

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "specify-cli" name = "specify-cli"
version = "0.8.0" version = "0.7.5"
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 = [

View File

@@ -320,9 +320,8 @@ try:
with open(os.environ['SPECKIT_REGISTRY']) as f: with open(os.environ['SPECKIT_REGISTRY']) as f:
data = json.load(f) data = json.load(f)
presets = data.get('presets', {}) presets = data.get('presets', {})
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10): for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10)):
if isinstance(meta, dict) and meta.get('enabled', True) is not False: print(pid)
print(pid)
except Exception: except Exception:
sys.exit(1) sys.exit(1)
" 2>/dev/null); then " 2>/dev/null); then
@@ -374,225 +373,3 @@ except Exception:
return 1 return 1
} }
# Resolve a template name to composed content using composition strategies.
# Reads strategy metadata from preset manifests and composes content
# from multiple layers using prepend, append, or wrap strategies.
#
# Usage: CONTENT=$(resolve_template_content "template-name" "$REPO_ROOT")
# Returns composed content string on stdout; exit code 1 if not found.
resolve_template_content() {
local template_name="$1"
local repo_root="$2"
local base="$repo_root/.specify/templates"
# Collect all layers (highest priority first)
local -a layer_paths=()
local -a layer_strategies=()
# Priority 1: Project overrides (always "replace")
local override="$base/overrides/${template_name}.md"
if [ -f "$override" ]; then
layer_paths+=("$override")
layer_strategies+=("replace")
fi
# Priority 2: Installed presets (sorted by priority from .registry)
local presets_dir="$repo_root/.specify/presets"
if [ -d "$presets_dir" ]; then
local registry_file="$presets_dir/.registry"
local sorted_presets=""
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
import json, sys, os
try:
with open(os.environ['SPECKIT_REGISTRY']) as f:
data = json.load(f)
presets = data.get('presets', {})
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10):
if isinstance(meta, dict) and meta.get('enabled', True) is not False:
print(pid)
except Exception:
sys.exit(1)
" 2>/dev/null); then
if [ -n "$sorted_presets" ]; then
local yaml_warned=false
while IFS= read -r preset_id; do
# Read strategy and file path from preset manifest
local strategy="replace"
local manifest_file=""
local manifest="$presets_dir/$preset_id/preset.yml"
if [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then
# Requires PyYAML; falls back to replace/convention if unavailable
local result
local py_stderr
py_stderr=$(mktemp)
result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c "
import sys, os
try:
import yaml
except ImportError:
print('yaml_missing', file=sys.stderr)
print('replace\t')
sys.exit(0)
try:
with open(os.environ['SPECKIT_MANIFEST']) as f:
data = yaml.safe_load(f)
for t in data.get('provides', {}).get('templates', []):
if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template':
print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
sys.exit(0)
print('replace\t')
except Exception:
print('replace\t')
" 2>"$py_stderr")
local parse_status=$?
if [ $parse_status -eq 0 ] && [ -n "$result" ]; then
IFS=$'\t' read -r strategy manifest_file <<< "$result"
strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]')
fi
if [ "$yaml_warned" = false ] && grep -q 'yaml_missing' "$py_stderr" 2>/dev/null; then
echo "Warning: PyYAML not available; composition strategies may be ignored" >&2
yaml_warned=true
fi
rm -f "$py_stderr"
fi
# Try manifest file path first, then convention path
local candidate=""
if [ -n "$manifest_file" ]; then
# Reject absolute paths and parent traversal
case "$manifest_file" in
/*|*../*|../*) manifest_file="" ;;
esac
fi
if [ -n "$manifest_file" ]; then
local mf="$presets_dir/$preset_id/$manifest_file"
[ -f "$mf" ] && candidate="$mf"
fi
if [ -z "$candidate" ]; then
local cf="$presets_dir/$preset_id/templates/${template_name}.md"
[ -f "$cf" ] && candidate="$cf"
fi
if [ -n "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("$strategy")
fi
done <<< "$sorted_presets"
fi
else
# python3 failed — fall back to unordered directory scan (replace only)
for preset in "$presets_dir"/*/; do
[ -d "$preset" ] || continue
local candidate="$preset/templates/${template_name}.md"
if [ -f "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("replace")
fi
done
fi
else
# No python3 or registry — fall back to unordered directory scan (replace only)
for preset in "$presets_dir"/*/; do
[ -d "$preset" ] || continue
local candidate="$preset/templates/${template_name}.md"
if [ -f "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("replace")
fi
done
fi
fi
# Priority 3: Extension-provided templates (always "replace")
local ext_dir="$repo_root/.specify/extensions"
if [ -d "$ext_dir" ]; then
for ext in "$ext_dir"/*/; do
[ -d "$ext" ] || continue
case "$(basename "$ext")" in .*) continue;; esac
local candidate="$ext/templates/${template_name}.md"
if [ -f "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("replace")
fi
done
fi
# Priority 4: Core templates (always "replace")
local core="$base/${template_name}.md"
if [ -f "$core" ]; then
layer_paths+=("$core")
layer_strategies+=("replace")
fi
local count=${#layer_paths[@]}
[ "$count" -eq 0 ] && return 1
# Check if any layer uses a non-replace strategy
local has_composition=false
for s in "${layer_strategies[@]}"; do
[ "$s" != "replace" ] && has_composition=true && break
done
# If the top (highest-priority) layer is replace, it wins entirely —
# lower layers are irrelevant regardless of their strategies.
if [ "${layer_strategies[0]}" = "replace" ]; then
cat "${layer_paths[0]}"
return 0
fi
if [ "$has_composition" = false ]; then
cat "${layer_paths[0]}"
return 0
fi
# Find the effective base: scan from highest priority (index 0) downward
# to find the nearest replace layer. Only compose layers above that base.
local base_idx=-1
local i
for (( i=0; i<count; i++ )); do
if [ "${layer_strategies[$i]}" = "replace" ]; then
base_idx=$i
break
fi
done
if [ $base_idx -lt 0 ]; then
return 1 # no base layer found
fi
# Read the base content; compose layers above the base (higher priority)
local content
content=$(cat "${layer_paths[$base_idx]}"; printf x)
content="${content%x}"
for (( i=base_idx-1; i>=0; i-- )); do
local path="${layer_paths[$i]}"
local strat="${layer_strategies[$i]}"
local layer_content
# Preserve trailing newlines
layer_content=$(cat "$path"; printf x)
layer_content="${layer_content%x}"
case "$strat" in
replace) content="$layer_content" ;;
prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;;
append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;;
wrap)
case "$layer_content" in
*'{CORE_TEMPLATE}'*) ;;
*) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;;
esac
while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do
local before="${layer_content%%\{CORE_TEMPLATE\}*}"
local after="${layer_content#*\{CORE_TEMPLATE\}}"
layer_content="${before}${content}${after}"
done
content="$layer_content"
;;
*) echo "Error: unknown strategy '$strat'" >&2; return 1 ;;
esac
done
printf '%s' "$content"
return 0
}

View File

@@ -287,21 +287,6 @@ function Test-DirHasFiles {
} }
} }
# Find a usable Python 3 executable (python3, python, or py -3).
# Returns the command/arguments as an array, or $null if none found.
function Get-Python3Command {
if (Get-Command python3 -ErrorAction SilentlyContinue) { return @('python3') }
if (Get-Command python -ErrorAction SilentlyContinue) {
$ver = & python --version 2>&1
if ($ver -match 'Python 3') { return @('python') }
}
if (Get-Command py -ErrorAction SilentlyContinue) {
$ver = & py -3 --version 2>&1
if ($ver -match 'Python 3') { return @('py', '-3') }
}
return $null
}
# Resolve a template name to a file path using the priority stack: # Resolve a template name to a file path using the priority stack:
# 1. .specify/templates/overrides/ # 1. .specify/templates/overrides/
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry) # 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
@@ -330,7 +315,6 @@ function Resolve-Template {
$presets = $registryData.presets $presets = $registryData.presets
if ($presets) { if ($presets) {
$sortedPresets = $presets.PSObject.Properties | $sortedPresets = $presets.PSObject.Properties |
Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } | Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
ForEach-Object { $_.Name } ForEach-Object { $_.Name }
} }
@@ -370,206 +354,3 @@ function Resolve-Template {
return $null return $null
} }
# Resolve a template name to composed content using composition strategies.
# Reads strategy metadata from preset manifests and composes content
# from multiple layers using prepend, append, or wrap strategies.
function Resolve-TemplateContent {
param(
[Parameter(Mandatory=$true)][string]$TemplateName,
[Parameter(Mandatory=$true)][string]$RepoRoot
)
$base = Join-Path $RepoRoot '.specify/templates'
# Collect all layers (highest priority first)
$layerPaths = @()
$layerStrategies = @()
# Priority 1: Project overrides (always "replace")
$override = Join-Path $base "overrides/$TemplateName.md"
if (Test-Path $override) {
$layerPaths += $override
$layerStrategies += 'replace'
}
# Priority 2: Installed presets (sorted by priority from .registry)
$presetsDir = Join-Path $RepoRoot '.specify/presets'
if (Test-Path $presetsDir) {
$registryFile = Join-Path $presetsDir '.registry'
$sortedPresets = @()
if (Test-Path $registryFile) {
try {
$registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
$presets = $registryData.presets
if ($presets) {
$sortedPresets = $presets.PSObject.Properties |
Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
ForEach-Object { $_.Name }
}
} catch {
$sortedPresets = @()
}
}
if ($sortedPresets.Count -gt 0) {
$pyCmd = Get-Python3Command
if (-not $pyCmd) {
# Check if any preset has strategy fields that would be ignored
foreach ($pid in $sortedPresets) {
$mf = Join-Path $presetsDir "$pid/preset.yml"
if ((Test-Path $mf) -and (Select-String -Path $mf -Pattern 'strategy:' -Quiet -ErrorAction SilentlyContinue)) {
Write-Warning "No Python 3 found; preset composition strategies will be ignored"
break
}
}
}
$yamlWarned = $false
foreach ($presetId in $sortedPresets) {
# Read strategy and file path from preset manifest
$strategy = 'replace'
$manifestFilePath = ''
$manifest = Join-Path $presetsDir "$presetId/preset.yml"
if ((Test-Path $manifest) -and $pyCmd) {
try {
# Use Python to parse YAML manifest for strategy and file path
$pyArgs = if ($pyCmd.Count -gt 1) { $pyCmd[1..($pyCmd.Count-1)] } else { @() }
$pyStderrFile = [System.IO.Path]::GetTempFileName()
$stratResult = & $pyCmd[0] @pyArgs -c @"
import sys
try:
import yaml
except ImportError:
print('yaml_missing', file=sys.stderr)
print('replace\t')
sys.exit(0)
try:
with open(sys.argv[1]) as f:
data = yaml.safe_load(f)
for t in data.get('provides', {}).get('templates', []):
if t.get('name') == sys.argv[2] and t.get('type', 'template') == 'template':
print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
sys.exit(0)
print('replace\t')
except Exception:
print('replace\t')
"@ $manifest $TemplateName 2>$pyStderrFile
if ($stratResult) {
$parts = $stratResult.Trim() -split "`t", 2
$strategy = $parts[0].ToLowerInvariant()
if ($parts.Count -gt 1 -and $parts[1]) { $manifestFilePath = $parts[1] }
}
if (-not $yamlWarned -and (Test-Path $pyStderrFile) -and (Get-Content $pyStderrFile -Raw -ErrorAction SilentlyContinue) -match 'yaml_missing') {
Write-Warning "PyYAML not available; composition strategies may be ignored"
$yamlWarned = $true
}
Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue
} catch {
$strategy = 'replace'
if ($pyStderrFile) { Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue }
}
}
# Try manifest file path first, then convention path
$candidate = $null
if ($manifestFilePath) {
# Reject absolute paths and parent traversal
if ([System.IO.Path]::IsPathRooted($manifestFilePath) -or $manifestFilePath -match '\.\.[\\/]') {
$manifestFilePath = ''
}
}
if ($manifestFilePath) {
$mf = Join-Path $presetsDir "$presetId/$manifestFilePath"
if (Test-Path $mf) { $candidate = $mf }
}
if (-not $candidate) {
$cf = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
if (Test-Path $cf) { $candidate = $cf }
}
if ($candidate) {
$layerPaths += $candidate
$layerStrategies += $strategy
}
}
} else {
# Fallback: alphabetical directory order (no registry or parse failure)
foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) {
$candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
if (Test-Path $candidate) {
$layerPaths += $candidate
$layerStrategies += 'replace'
}
}
}
}
# Priority 3: Extension-provided templates (always "replace")
$extDir = Join-Path $RepoRoot '.specify/extensions'
if (Test-Path $extDir) {
foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
$candidate = Join-Path $ext.FullName "templates/$TemplateName.md"
if (Test-Path $candidate) {
$layerPaths += $candidate
$layerStrategies += 'replace'
}
}
}
# Priority 4: Core templates (always "replace")
$core = Join-Path $base "$TemplateName.md"
if (Test-Path $core) {
$layerPaths += $core
$layerStrategies += 'replace'
}
if ($layerPaths.Count -eq 0) { return $null }
# If the top (highest-priority) layer is replace, it wins entirely —
# lower layers are irrelevant regardless of their strategies.
if ($layerStrategies[0] -eq 'replace') {
return (Get-Content $layerPaths[0] -Raw)
}
# Check if any layer uses a non-replace strategy
$hasComposition = $false
foreach ($s in $layerStrategies) {
if ($s -ne 'replace') { $hasComposition = $true; break }
}
if (-not $hasComposition) {
return (Get-Content $layerPaths[0] -Raw)
}
# Find the effective base: scan from highest priority (index 0) downward
# to find the nearest replace layer. Only compose layers above that base.
$baseIdx = -1
for ($i = 0; $i -lt $layerPaths.Count; $i++) {
if ($layerStrategies[$i] -eq 'replace') {
$baseIdx = $i
break
}
}
if ($baseIdx -lt 0) { return $null }
$content = Get-Content $layerPaths[$baseIdx] -Raw
for ($i = $baseIdx - 1; $i -ge 0; $i--) {
$path = $layerPaths[$i]
$strat = $layerStrategies[$i]
$layerContent = Get-Content $path -Raw
switch ($strat) {
'replace' { $content = $layerContent }
'prepend' { $content = "$layerContent`n`n$content" }
'append' { $content = "$content`n`n$layerContent" }
'wrap' {
if (-not $layerContent.Contains('{CORE_TEMPLATE}')) {
throw "Wrap strategy missing {CORE_TEMPLATE} placeholder"
}
$content = $layerContent.Replace('{CORE_TEMPLATE}', $content)
}
default { throw "Unknown strategy: $strat" }
}
}
return $content
}

View File

@@ -722,18 +722,12 @@ def _install_shared_infra(
project_path: Path, project_path: Path,
script_type: str, script_type: str,
tracker: StepTracker | None = None, tracker: StepTracker | None = None,
force: bool = False,
) -> bool: ) -> bool:
"""Install shared infrastructure files into *project_path*. """Install shared infrastructure files into *project_path*.
Copies ``.specify/scripts/`` and ``.specify/templates/`` from the Copies ``.specify/scripts/`` and ``.specify/templates/`` from the
bundled core_pack or source checkout. Tracks all installed files bundled core_pack or source checkout. Tracks all installed files
in ``speckit.manifest.json``. in ``speckit.manifest.json``.
When *force* is ``True``, existing files are overwritten with the
latest bundled versions. When ``False`` (default), only missing
files are added and existing ones are skipped.
Returns ``True`` on success. Returns ``True`` on success.
""" """
from .integrations.manifest import IntegrationManifest from .integrations.manifest import IntegrationManifest
@@ -758,11 +752,12 @@ def _install_shared_infra(
if variant_src.is_dir(): if variant_src.is_dir():
dest_variant = dest_scripts / variant_dir dest_variant = dest_scripts / variant_dir
dest_variant.mkdir(parents=True, exist_ok=True) dest_variant.mkdir(parents=True, exist_ok=True)
# Merge without overwriting — only add files that don't exist yet
for src_path in variant_src.rglob("*"): for src_path in variant_src.rglob("*"):
if src_path.is_file(): if src_path.is_file():
rel_path = src_path.relative_to(variant_src) rel_path = src_path.relative_to(variant_src)
dst_path = dest_variant / rel_path dst_path = dest_variant / rel_path
if dst_path.exists() and not force: if dst_path.exists():
skipped_files.append(str(dst_path.relative_to(project_path))) skipped_files.append(str(dst_path.relative_to(project_path)))
else: else:
dst_path.parent.mkdir(parents=True, exist_ok=True) dst_path.parent.mkdir(parents=True, exist_ok=True)
@@ -783,7 +778,7 @@ def _install_shared_infra(
for f in templates_src.iterdir(): for f in templates_src.iterdir():
if f.is_file() and f.name != "vscode-settings.json" and not f.name.startswith("."): if f.is_file() and f.name != "vscode-settings.json" and not f.name.startswith("."):
dst = dest_templates / f.name dst = dest_templates / f.name
if dst.exists() and not force: if dst.exists():
skipped_files.append(str(dst.relative_to(project_path))) skipped_files.append(str(dst.relative_to(project_path)))
else: else:
shutil.copy2(f, dst) shutil.copy2(f, dst)
@@ -791,15 +786,10 @@ def _install_shared_infra(
manifest.record_existing(rel) manifest.record_existing(rel)
if skipped_files: if skipped_files:
console.print( import logging
f"[yellow]⚠[/yellow] {len(skipped_files)} shared infrastructure file(s) already exist and were not updated:" logging.getLogger(__name__).warning(
) "The following shared files already exist and were not overwritten:\n%s",
for f in skipped_files: "\n".join(f" {f}" for f in skipped_files),
console.print(f" {f}")
console.print(
"To refresh shared infrastructure, run "
"[cyan]specify init --here --force[/cyan] or "
"[cyan]specify integration upgrade --force[/cyan]."
) )
manifest.save() manifest.save()
@@ -1268,12 +1258,6 @@ def init(
integration_parsed_options["commands_dir"] = ai_commands_dir integration_parsed_options["commands_dir"] = ai_commands_dir
if ai_skills: if ai_skills:
integration_parsed_options["skills"] = True integration_parsed_options["skills"] = True
# Parse --integration-options and merge into parsed_options so
# flags like --skills reach the integration's setup().
if integration_options:
extra = _parse_integration_options(resolved_integration, integration_options)
if extra:
integration_parsed_options.update(extra)
resolved_integration.setup( resolved_integration.setup(
project_path, manifest, project_path, manifest,
@@ -1295,7 +1279,7 @@ def init(
# Install shared infrastructure (scripts, templates) # Install shared infrastructure (scripts, templates)
tracker.start("shared-infra") tracker.start("shared-infra")
_install_shared_infra(project_path, selected_script, tracker=tracker, force=force) _install_shared_infra(project_path, selected_script, tracker=tracker)
tracker.complete("shared-infra", f"scripts ({selected_script}) + templates") tracker.complete("shared-infra", f"scripts ({selected_script}) + templates")
ensure_constitution_from_template(project_path, tracker=tracker) ensure_constitution_from_template(project_path, tracker=tracker)
@@ -1399,10 +1383,8 @@ def init(
} }
# Ensure ai_skills is set for SkillsIntegration so downstream # Ensure ai_skills is set for SkillsIntegration so downstream
# tools (extensions, presets) emit SKILL.md overrides correctly. # tools (extensions, presets) emit SKILL.md overrides correctly.
# Also set for integrations running in skills mode (e.g. Copilot
# with --skills).
from .integrations.base import SkillsIntegration as _SkillsPersist from .integrations.base import SkillsIntegration as _SkillsPersist
if isinstance(resolved_integration, _SkillsPersist) or getattr(resolved_integration, "_skills_mode", False): if isinstance(resolved_integration, _SkillsPersist):
init_opts["ai_skills"] = True init_opts["ai_skills"] = True
save_init_options(project_path, init_opts) save_init_options(project_path, init_opts)
@@ -1514,7 +1496,7 @@ def init(
# Determine skill display mode for the next-steps panel. # Determine skill display mode for the next-steps panel.
# Skills integrations (codex, kimi, agy, trae, cursor-agent) should show skill invocation syntax. # Skills integrations (codex, kimi, agy, trae, cursor-agent) should show skill invocation syntax.
from .integrations.base import SkillsIntegration as _SkillsInt from .integrations.base import SkillsIntegration as _SkillsInt
_is_skills_integration = isinstance(resolved_integration, _SkillsInt) or getattr(resolved_integration, "_skills_mode", False) _is_skills_integration = isinstance(resolved_integration, _SkillsInt)
codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration) codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration)
claude_skill_mode = selected_ai == "claude" and (ai_skills or _is_skills_integration) claude_skill_mode = selected_ai == "claude" and (ai_skills or _is_skills_integration)
@@ -1522,8 +1504,7 @@ def init(
agy_skill_mode = selected_ai == "agy" and _is_skills_integration agy_skill_mode = selected_ai == "agy" and _is_skills_integration
trae_skill_mode = selected_ai == "trae" trae_skill_mode = selected_ai == "trae"
cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration) cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration)
copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode
native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode
if codex_skill_mode and not ai_skills: if codex_skill_mode and not ai_skills:
# Integration path installed skills; show the helpful notice # Integration path installed skills; show the helpful notice
@@ -1544,7 +1525,7 @@ def init(
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}"
if cursor_agent_skill_mode or copilot_skill_mode: if cursor_agent_skill_mode:
return f"/speckit-{name}" return f"/speckit-{name}"
return f"/speckit.{name}" return f"/speckit.{name}"
@@ -2175,7 +2156,7 @@ def _update_init_options_for_integration(
opts["context_file"] = integration.context_file opts["context_file"] = integration.context_file
if script_type: if script_type:
opts["script"] = script_type opts["script"] = script_type
if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False): if isinstance(integration, SkillsIntegration):
opts["ai_skills"] = True opts["ai_skills"] = True
else: else:
opts.pop("ai_skills", None) opts.pop("ai_skills", None)
@@ -2465,8 +2446,9 @@ def integration_upgrade(
selected_script = _resolve_script_type(project_root, script) selected_script = _resolve_script_type(project_root, script)
# Ensure shared infrastructure is up to date; --force overwrites existing files. # Ensure shared infrastructure is present (safe to run unconditionally;
_install_shared_infra(project_root, selected_script, force=force) # _install_shared_infra merges missing files without overwriting).
_install_shared_infra(project_root, selected_script)
if os.name != "nt": if os.name != "nt":
ensure_executable_scripts(project_root) ensure_executable_scripts(project_root)
@@ -2762,58 +2744,14 @@ def preset_resolve(
raise typer.Exit(1) raise typer.Exit(1)
resolver = PresetResolver(project_root) resolver = PresetResolver(project_root)
layers = resolver.collect_all_layers(template_name) result = resolver.resolve_with_source(template_name)
if layers: if result:
# Use the highest-priority layer for display because the final output console.print(f" [bold]{template_name}[/bold]: {result['path']}")
# may be composed and may not map to resolve_with_source()'s single path. console.print(f" [dim](from: {result['source']})[/dim]")
display_layer = layers[0]
console.print(f" [bold]{template_name}[/bold]: {display_layer['path']}")
console.print(f" [dim](top layer from: {display_layer['source']})[/dim]")
has_composition = (
layers[0]["strategy"] != "replace"
and any(layer["strategy"] != "replace" for layer in layers)
)
if has_composition:
# Verify composition is actually possible
try:
composed = resolver.resolve_content(template_name)
except Exception as exc:
composed = None
console.print(f" [yellow]Warning: composition error: {exc}[/yellow]")
if composed is None:
console.print(" [yellow]Warning: composition cannot produce output (no base layer with 'replace' strategy)[/yellow]")
else:
console.print(" [dim]Final output is composed from multiple preset layers; the path above is the highest-priority contributing layer.[/dim]")
console.print("\n [bold]Composition chain:[/bold]")
# Compute the effective base: first replace layer scanning from
# highest priority (matching resolve_content top-down logic).
# Only show layers from the base upward (lower layers are ignored).
effective_base_idx = None
for idx, lyr in enumerate(layers):
if lyr["strategy"] == "replace":
effective_base_idx = idx
break
# Show only contributing layers (base and above)
if effective_base_idx is not None:
contributing = layers[:effective_base_idx + 1]
else:
contributing = layers
for i, layer in enumerate(reversed(contributing)):
strategy_label = layer["strategy"]
if strategy_label == "replace" and i == 0:
strategy_label = "base"
console.print(f" {i + 1}. [{strategy_label}] {layer['source']}{layer['path']}")
else: else:
# No layers found — fall back to resolve_with_source for non-composition cases console.print(f" [yellow]{template_name}[/yellow]: not found")
result = resolver.resolve_with_source(template_name) console.print(" [dim]No template with this name exists in the resolution stack[/dim]")
if result:
console.print(f" [bold]{template_name}[/bold]: {result['path']}")
console.print(f" [dim](from: {result['source']})[/dim]")
else:
console.print(f" [yellow]{template_name}[/yellow]: not found")
console.print(" [dim]No template with this name exists in the resolution stack[/dim]")
@preset_app.command("info") @preset_app.command("info")

View File

@@ -8,7 +8,7 @@ command files into agent-specific directories in the correct format.
import os import os
from pathlib import Path from pathlib import Path
from typing import Dict, List, Any, Optional from typing import Dict, List, Any
import platform import platform
import re import re
@@ -652,49 +652,6 @@ class CommandRegistrar:
return results return results
def register_commands_for_non_skill_agents(
self,
commands: List[Dict[str, Any]],
source_id: str,
source_dir: Path,
project_root: Path,
context_note: Optional[str] = None,
) -> Dict[str, List[str]]:
"""Register commands for all non-skill agents in the project.
Like register_commands_for_all_agents but skips skill-based agents
(those with extension '/SKILL.md'). Used by reconciliation to avoid
overwriting properly formatted SKILL.md files.
Args:
commands: List of command info dicts
source_id: Identifier of the source
source_dir: Directory containing command source files
project_root: Path to project root
context_note: Custom context comment for markdown output
Returns:
Dictionary mapping agent names to list of registered commands
"""
results = {}
self._ensure_configs()
for agent_name, agent_config in self.AGENT_CONFIGS.items():
if agent_config.get("extension") == "/SKILL.md":
continue
agent_dir = project_root / agent_config["dir"]
if agent_dir.exists():
try:
registered = self.register_commands(
agent_name, commands, source_id,
source_dir, project_root,
context_note=context_note,
)
if registered:
results[agent_name] = registered
except ValueError:
continue
return results
def unregister_commands( def unregister_commands(
self, registered_commands: Dict[str, List[str]], project_root: Path self, registered_commands: Dict[str, List[str]], project_root: Path
) -> None: ) -> None:

View File

@@ -5,10 +5,6 @@ Copilot has several unique behaviors compared to standard markdown agents:
- Each command gets a companion ``.prompt.md`` file in ``.github/prompts/`` - Each command gets a companion ``.prompt.md`` file in ``.github/prompts/``
- Installs ``.vscode/settings.json`` with prompt file recommendations - Installs ``.vscode/settings.json`` with prompt file recommendations
- Context file lives at ``.github/copilot-instructions.md`` - Context file lives at ``.github/copilot-instructions.md``
When ``--skills`` is passed via ``--integration-options``, Copilot scaffolds
commands as ``speckit-<name>/SKILL.md`` directories under ``.github/skills/``
instead. The two modes are mutually exclusive.
""" """
from __future__ import annotations from __future__ import annotations
@@ -20,7 +16,7 @@ import warnings
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from ..base import IntegrationBase, IntegrationOption, SkillsIntegration from ..base import IntegrationBase
from ..manifest import IntegrationManifest from ..manifest import IntegrationManifest
@@ -48,40 +44,12 @@ def _allow_all() -> bool:
return True return True
class _CopilotSkillsHelper(SkillsIntegration):
"""Internal helper used when Copilot is scaffolded in skills mode.
Not registered in the integration registry — only used as a delegate
by ``CopilotIntegration`` when ``--skills`` is passed.
"""
key = "copilot"
config = {
"name": "GitHub Copilot",
"folder": ".github/",
"commands_subdir": "skills",
"install_url": "https://docs.github.com/en/copilot/concepts/agents/copilot-cli/about-copilot-cli",
"requires_cli": False,
}
registrar_config = {
"dir": ".github/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = ".github/copilot-instructions.md"
class CopilotIntegration(IntegrationBase): class CopilotIntegration(IntegrationBase):
"""Integration for GitHub Copilot (VS Code IDE + CLI). """Integration for GitHub Copilot (VS Code IDE + CLI).
The IDE integration (``requires_cli: False``) installs ``.agent.md`` The IDE integration (``requires_cli: False``) installs ``.agent.md``
command files. Workflow dispatch additionally requires the command files. Workflow dispatch additionally requires the
``copilot`` CLI to be installed separately. ``copilot`` CLI to be installed separately.
When ``--skills`` is passed via ``--integration-options``, commands
are scaffolded as ``speckit-<name>/SKILL.md`` under ``.github/skills/``
instead of the default ``.agent.md`` + ``.prompt.md`` layout.
""" """
key = "copilot" key = "copilot"
@@ -100,20 +68,6 @@ class CopilotIntegration(IntegrationBase):
} }
context_file = ".github/copilot-instructions.md" context_file = ".github/copilot-instructions.md"
# Mutable flag set by setup() — indicates the active scaffolding mode.
_skills_mode: bool = False
@classmethod
def options(cls) -> list[IntegrationOption]:
return [
IntegrationOption(
"--skills",
is_flag=True,
default=False,
help="Scaffold commands as agent skills (speckit-<name>/SKILL.md) instead of .agent.md files",
),
]
def build_exec_args( def build_exec_args(
self, self,
prompt: str, prompt: str,
@@ -138,19 +92,7 @@ class CopilotIntegration(IntegrationBase):
return args return args
def build_command_invocation(self, command_name: str, args: str = "") -> str: def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""Build the native invocation for a Copilot command. """Copilot agents are not slash-commands — just return the args as prompt."""
Default mode: agents are not slash-commands — return args as prompt.
Skills mode: ``/speckit-<stem>`` slash-command dispatch.
"""
if self._skills_mode:
stem = command_name
if "." in stem:
stem = stem.rsplit(".", 1)[-1]
invocation = f"/speckit-{stem}"
if args:
invocation = f"{invocation} {args}"
return invocation
return args or "" return args or ""
def dispatch_command( def dispatch_command(
@@ -168,37 +110,19 @@ class CopilotIntegration(IntegrationBase):
Copilot ``.agent.md`` files are agents, not skills. The CLI Copilot ``.agent.md`` files are agents, not skills. The CLI
selects them with ``--agent <name>`` and the prompt is just selects them with ``--agent <name>`` and the prompt is just
the user's arguments. the user's arguments.
In skills mode, the prompt includes the skill invocation
(``/speckit-<stem>``).
""" """
import subprocess import subprocess
stem = command_name stem = command_name
if "." in stem: if "." in stem:
stem = stem.rsplit(".", 1)[-1] stem = stem.rsplit(".", 1)[-1]
agent_name = f"speckit.{stem}"
# Detect skills mode from project layout when not set via setup() prompt = args or ""
skills_mode = self._skills_mode cli_args = [
if not skills_mode and project_root: "copilot", "-p", prompt,
skills_dir = project_root / ".github" / "skills" "--agent", agent_name,
if skills_dir.is_dir(): ]
skills_mode = any(
d.is_dir() and (d / "SKILL.md").is_file()
for d in skills_dir.glob("speckit-*")
)
if skills_mode:
prompt = f"/speckit-{stem}"
if args:
prompt = f"{prompt} {args}"
else:
agent_name = f"speckit.{stem}"
prompt = args or ""
cli_args = ["copilot", "-p", prompt]
if not skills_mode:
cli_args.extend(["--agent", agent_name])
if _allow_all(): if _allow_all():
cli_args.append("--yolo") cli_args.append("--yolo")
if model: if model:
@@ -244,59 +168,6 @@ class CopilotIntegration(IntegrationBase):
"""Copilot commands use ``.agent.md`` extension.""" """Copilot commands use ``.agent.md`` extension."""
return f"speckit.{template_name}.agent.md" return f"speckit.{template_name}.agent.md"
def post_process_skill_content(self, content: str) -> str:
"""Inject Copilot-specific ``mode:`` field into SKILL.md frontmatter.
Inserts ``mode: speckit.<stem>`` before the closing ``---`` so
Copilot can associate the skill with its agent mode.
"""
lines = content.splitlines(keepends=True)
# Extract skill name from frontmatter to derive the mode value
dash_count = 0
skill_name = ""
for line in lines:
stripped = line.rstrip("\n\r")
if stripped == "---":
dash_count += 1
if dash_count == 2:
break
continue
if dash_count == 1:
if stripped.startswith("mode:"):
return content # already present
if stripped.startswith("name:"):
# Parse: name: "speckit-plan" → speckit.plan
val = stripped.split(":", 1)[1].strip().strip('"').strip("'")
# Convert speckit-plan → speckit.plan
if val.startswith("speckit-"):
skill_name = "speckit." + val[len("speckit-"):]
else:
skill_name = val
if not skill_name:
return content
# Inject mode: before the closing --- of frontmatter
out: list[str] = []
dash_count = 0
injected = False
for line in lines:
stripped = line.rstrip("\n\r")
if stripped == "---":
dash_count += 1
if dash_count == 2 and not injected:
if line.endswith("\r\n"):
eol = "\r\n"
elif line.endswith("\n"):
eol = "\n"
else:
eol = ""
out.append(f"mode: {skill_name}{eol}")
injected = True
out.append(line)
return "".join(out)
def setup( def setup(
self, self,
project_root: Path, project_root: Path,
@@ -306,24 +177,10 @@ class CopilotIntegration(IntegrationBase):
) -> list[Path]: ) -> list[Path]:
"""Install copilot commands, companion prompts, and VS Code settings. """Install copilot commands, companion prompts, and VS Code settings.
When ``parsed_options["skills"]`` is truthy, delegates to skills Uses base class primitives to: read templates, process them
scaffolding (``speckit-<name>/SKILL.md`` under ``.github/skills/``). (replace placeholders, strip script blocks, rewrite paths),
Otherwise uses the default ``.agent.md`` + ``.prompt.md`` layout. write as ``.agent.md``, then add companion prompts and VS Code settings.
""" """
parsed_options = parsed_options or {}
self._skills_mode = bool(parsed_options.get("skills"))
if self._skills_mode:
return self._setup_skills(project_root, manifest, parsed_options, **opts)
return self._setup_default(project_root, manifest, parsed_options, **opts)
def _setup_default(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Default mode: .agent.md + .prompt.md + VS Code settings merge."""
project_root_resolved = project_root.resolve() project_root_resolved = project_root.resolve()
if manifest.project_root != project_root_resolved: if manifest.project_root != project_root_resolved:
raise ValueError( raise ValueError(
@@ -395,37 +252,6 @@ class CopilotIntegration(IntegrationBase):
return created return created
def _setup_skills(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Skills mode: delegate to ``_CopilotSkillsHelper`` then post-process."""
helper = _CopilotSkillsHelper()
created = SkillsIntegration.setup(
helper, project_root, manifest, parsed_options, **opts
)
# Post-process generated skill files with Copilot-specific frontmatter
skills_dir = helper.skills_dest(project_root).resolve()
for path in created:
try:
path.resolve().relative_to(skills_dir)
except ValueError:
continue
if path.name != "SKILL.md":
continue
content = path.read_text(encoding="utf-8")
updated = self.post_process_skill_content(content)
if updated != content:
path.write_bytes(updated.encode("utf-8"))
self.record_file_in_manifest(path, project_root, manifest)
return created
def _vscode_settings_path(self) -> Path | None: def _vscode_settings_path(self) -> Path | None:
"""Return path to the bundled vscode-settings.json template.""" """Return path to the bundled vscode-settings.json template."""
tpl_dir = self.shared_templates_dir() tpl_dir = self.shared_templates_dir()

File diff suppressed because it is too large Load Diff

View File

@@ -173,117 +173,26 @@ class TestInitIntegrationFlag:
assert "speckit-specify" in command_file.read_text(encoding="utf-8") assert "speckit-specify" in command_file.read_text(encoding="utf-8")
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
def test_shared_infra_skips_existing_files_without_force(self, tmp_path): def test_shared_infra_skips_existing_files(self, tmp_path):
"""Pre-existing shared files are not overwritten without --force.""" """Pre-existing shared files are not overwritten by _install_shared_infra."""
from specify_cli import _install_shared_infra
project = tmp_path / "skip-test"
project.mkdir()
(project / ".specify").mkdir()
# Pre-create a shared script with custom content
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
custom_content = "# user-modified common.sh\n"
(scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")
# Pre-create a shared template with custom content
templates_dir = project / ".specify" / "templates"
templates_dir.mkdir(parents=True)
custom_template = "# user-modified spec-template\n"
(templates_dir / "spec-template.md").write_text(custom_template, encoding="utf-8")
_install_shared_infra(project, "sh", force=False)
# User's files should be preserved (not overwritten)
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content
assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") == custom_template
# Other shared files should still be installed
assert (scripts_dir / "setup-plan.sh").exists()
assert (templates_dir / "plan-template.md").exists()
def test_shared_infra_overwrites_existing_files_with_force(self, tmp_path):
"""Pre-existing shared files ARE overwritten when force=True."""
from specify_cli import _install_shared_infra
project = tmp_path / "force-test"
project.mkdir()
(project / ".specify").mkdir()
# Pre-create a shared script with custom content
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
custom_content = "# user-modified common.sh\n"
(scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")
# Pre-create a shared template with custom content
templates_dir = project / ".specify" / "templates"
templates_dir.mkdir(parents=True)
custom_template = "# user-modified spec-template\n"
(templates_dir / "spec-template.md").write_text(custom_template, encoding="utf-8")
_install_shared_infra(project, "sh", force=True)
# Files should be overwritten with bundled versions
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content
assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") != custom_template
# Other shared files should also be installed
assert (scripts_dir / "setup-plan.sh").exists()
assert (templates_dir / "plan-template.md").exists()
def test_shared_infra_skip_warning_displayed(self, tmp_path, capsys):
"""Console warning is displayed when files are skipped."""
from specify_cli import _install_shared_infra
project = tmp_path / "warn-test"
project.mkdir()
(project / ".specify").mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
(scripts_dir / "common.sh").write_text("# custom\n", encoding="utf-8")
_install_shared_infra(project, "sh", force=False)
captured = capsys.readouterr()
assert "already exist and were not updated" in captured.out
assert "specify init --here --force" in captured.out
# Rich may wrap long lines; normalize whitespace for the second command
normalized = " ".join(captured.out.split())
assert "specify integration upgrade --force" in normalized
def test_shared_infra_no_warning_when_forced(self, tmp_path, capsys):
"""No skip warning when force=True (all files overwritten)."""
from specify_cli import _install_shared_infra
project = tmp_path / "no-warn-test"
project.mkdir()
(project / ".specify").mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
(scripts_dir / "common.sh").write_text("# custom\n", encoding="utf-8")
_install_shared_infra(project, "sh", force=True)
captured = capsys.readouterr()
assert "already exist and were not updated" not in captured.out
def test_init_here_force_overwrites_shared_infra(self, tmp_path):
"""E2E: specify init --here --force overwrites shared infra files."""
from typer.testing import CliRunner from typer.testing import CliRunner
from specify_cli import app from specify_cli import app
project = tmp_path / "e2e-force" project = tmp_path / "skip-test"
project.mkdir() project.mkdir()
# Pre-create a shared script with custom content
scripts_dir = project / ".specify" / "scripts" / "bash" scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True) scripts_dir.mkdir(parents=True)
custom_content = "# user-modified common.sh\n" custom_content = "# user-modified common.sh\n"
(scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8") (scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")
# Pre-create a shared template with custom content
templates_dir = project / ".specify" / "templates"
templates_dir.mkdir(parents=True)
custom_template = "# user-modified spec-template\n"
(templates_dir / "spec-template.md").write_text(custom_template, encoding="utf-8")
old_cwd = os.getcwd() old_cwd = os.getcwd()
try: try:
os.chdir(project) os.chdir(project)
@@ -298,40 +207,14 @@ class TestInitIntegrationFlag:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0 assert result.exit_code == 0
# --force should overwrite the custom file
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content
def test_init_here_without_force_preserves_shared_infra(self, tmp_path): # User's files should be preserved
"""E2E: specify init --here (no --force) preserves existing shared infra files."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "e2e-no-force"
project.mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
custom_content = "# user-modified common.sh\n"
(scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here",
"--integration", "copilot",
"--script", "sh",
"--no-git",
], input="y\n", catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# Without --force, custom file should be preserved
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content
# Warning about skipped files should appear assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") == custom_template
assert "not updated" in result.output
# Other shared files should still be installed
assert (scripts_dir / "setup-plan.sh").exists()
assert (templates_dir / "plan-template.md").exists()
class TestForceExistingDirectory: class TestForceExistingDirectory:

View File

@@ -3,8 +3,6 @@
import json import json
import os import os
import yaml
from specify_cli.integrations import get_integration from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest from specify_cli.integrations.manifest import IntegrationManifest
@@ -277,420 +275,3 @@ class TestCopilotIntegration:
f"Missing: {sorted(set(expected) - set(actual))}\n" f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}" f"Extra: {sorted(set(actual) - set(expected))}"
) )
class TestCopilotSkillsMode:
"""Tests for Copilot integration in --skills mode."""
_SKILL_COMMANDS = [
"analyze", "checklist", "clarify", "constitution",
"implement", "plan", "specify", "tasks", "taskstoissues",
]
def _make_copilot(self):
from specify_cli.integrations.copilot import CopilotIntegration
return CopilotIntegration()
def _setup_skills(self, copilot, tmp_path):
m = IntegrationManifest("copilot", tmp_path)
created = copilot.setup(tmp_path, m, parsed_options={"skills": True})
return created, m
# -- Options ----------------------------------------------------------
def test_options_include_skills_flag(self):
copilot = get_integration("copilot")
opts = copilot.options()
skills_opts = [o for o in opts if o.name == "--skills"]
assert len(skills_opts) == 1
assert skills_opts[0].is_flag is True
assert skills_opts[0].default is False
# -- Skills directory structure ---------------------------------------
def test_skills_creates_skill_files(self, tmp_path):
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
assert len(created) > 0
skill_files = [f for f in created if f.name == "SKILL.md"]
assert len(skill_files) > 0
for f in skill_files:
assert f.exists()
assert f.parent.name.startswith("speckit-")
def test_skills_directory_under_github_skills(self, tmp_path):
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
skills_dir = tmp_path / ".github" / "skills"
assert skills_dir.is_dir()
skill_files = [f for f in created if f.name == "SKILL.md"]
for f in skill_files:
assert f.resolve().parent.parent == skills_dir.resolve(), (
f"{f} is not under {skills_dir}"
)
def test_skills_directory_structure(self, tmp_path):
"""Each command produces speckit-<name>/SKILL.md."""
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
skill_files = [f for f in created if f.name == "SKILL.md"]
expected_commands = set(self._SKILL_COMMANDS)
actual_commands = set()
for f in skill_files:
skill_dir_name = f.parent.name
assert skill_dir_name.startswith("speckit-")
actual_commands.add(skill_dir_name.removeprefix("speckit-"))
assert actual_commands == expected_commands
# -- No companion files in skills mode --------------------------------
def test_skills_no_prompt_md_companions(self, tmp_path):
"""Skills mode must not generate .prompt.md companion files."""
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
prompt_files = [f for f in created if f.name.endswith(".prompt.md")]
assert prompt_files == []
prompts_dir = tmp_path / ".github" / "prompts"
if prompts_dir.exists():
assert list(prompts_dir.iterdir()) == []
def test_skills_no_vscode_settings(self, tmp_path):
"""Skills mode must not create or merge .vscode/settings.json."""
copilot = self._make_copilot()
self._setup_skills(copilot, tmp_path)
settings = tmp_path / ".vscode" / "settings.json"
assert not settings.exists()
def test_skills_no_agent_md_files(self, tmp_path):
"""Skills mode must not produce .agent.md files."""
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
agent_files = [f for f in created if f.name.endswith(".agent.md")]
assert agent_files == []
# -- Frontmatter structure --------------------------------------------
def test_skill_frontmatter_structure(self, tmp_path):
"""SKILL.md must have name, description, compatibility, metadata."""
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
skill_files = [f for f in created if f.name == "SKILL.md"]
for f in skill_files:
content = f.read_text(encoding="utf-8")
assert content.startswith("---\n"), f"{f} missing frontmatter"
parts = content.split("---", 2)
fm = yaml.safe_load(parts[1])
assert "name" in fm, f"{f} frontmatter missing 'name'"
assert "description" in fm, f"{f} frontmatter missing 'description'"
assert "compatibility" in fm, f"{f} frontmatter missing 'compatibility'"
assert "metadata" in fm, f"{f} frontmatter missing 'metadata'"
assert fm["metadata"]["author"] == "github-spec-kit"
# -- Copilot-specific post-processing ---------------------------------
def test_post_process_skill_content_injects_mode(self):
"""post_process_skill_content() should inject mode: field."""
copilot = self._make_copilot()
content = (
"---\n"
'name: "speckit-plan"\n'
'description: "Plan workflow"\n'
"---\n"
"\nBody content\n"
)
updated = copilot.post_process_skill_content(content)
assert "mode: speckit.plan" in updated
def test_post_process_idempotent(self):
"""post_process_skill_content() must be idempotent."""
copilot = self._make_copilot()
content = (
"---\n"
'name: "speckit-plan"\n'
'description: "Plan workflow"\n'
"---\n"
"\nBody content\n"
)
first = copilot.post_process_skill_content(content)
second = copilot.post_process_skill_content(first)
assert first == second
def test_skills_have_mode_in_frontmatter(self, tmp_path):
"""Generated SKILL.md files should have mode: field from post-processing."""
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
skill_files = [f for f in created if f.name == "SKILL.md"]
assert len(skill_files) > 0
for f in skill_files:
content = f.read_text(encoding="utf-8")
parts = content.split("---", 2)
fm = yaml.safe_load(parts[1])
assert "mode" in fm, f"{f} frontmatter missing 'mode'"
# mode should be speckit.<stem>
skill_dir_name = f.parent.name
stem = skill_dir_name.removeprefix("speckit-")
assert fm["mode"] == f"speckit.{stem}"
# -- Template processing ----------------------------------------------
def test_skills_templates_are_processed(self, tmp_path):
"""Skill body must have placeholders replaced."""
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
skill_files = [f for f in created if f.name == "SKILL.md"]
assert len(skill_files) > 0
for f in skill_files:
content = f.read_text(encoding="utf-8")
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
def test_skill_body_has_content(self, tmp_path):
"""Each SKILL.md body should contain template content."""
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
skill_files = [f for f in created if f.name == "SKILL.md"]
for f in skill_files:
content = f.read_text(encoding="utf-8")
parts = content.split("---", 2)
body = parts[2].strip() if len(parts) >= 3 else ""
assert len(body) > 0, f"{f} has empty body"
def test_plan_references_correct_context_file(self, tmp_path):
"""The generated plan skill must reference copilot's context file."""
copilot = self._make_copilot()
self._setup_skills(copilot, tmp_path)
plan_file = tmp_path / ".github" / "skills" / "speckit-plan" / "SKILL.md"
assert plan_file.exists()
content = plan_file.read_text(encoding="utf-8")
assert copilot.context_file in content
assert "__CONTEXT_FILE__" not in content
# -- Manifest tracking ------------------------------------------------
def test_all_files_tracked_in_manifest(self, tmp_path):
copilot = self._make_copilot()
created, m = self._setup_skills(copilot, tmp_path)
for f in created:
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
assert rel in m.files, f"{rel} not tracked in manifest"
# -- Install/uninstall roundtrip --------------------------------------
def test_install_uninstall_roundtrip(self, tmp_path):
copilot = self._make_copilot()
m = IntegrationManifest("copilot", tmp_path)
created = copilot.install(tmp_path, m, parsed_options={"skills": True})
assert len(created) > 0
m.save()
for f in created:
assert f.exists()
removed, skipped = copilot.uninstall(tmp_path, m)
assert len(removed) == len(created)
assert skipped == []
def test_modified_file_survives_uninstall(self, tmp_path):
copilot = self._make_copilot()
m = IntegrationManifest("copilot", tmp_path)
created = copilot.install(tmp_path, m, parsed_options={"skills": True})
m.save()
modified_file = created[0]
modified_file.write_text("user modified this", encoding="utf-8")
removed, skipped = copilot.uninstall(tmp_path, m)
assert modified_file.exists()
assert modified_file in skipped
# -- build_command_invocation -----------------------------------------
def test_build_command_invocation_skills_mode(self):
copilot = self._make_copilot()
copilot._skills_mode = True
assert copilot.build_command_invocation("speckit.plan") == "/speckit-plan"
assert copilot.build_command_invocation("plan") == "/speckit-plan"
assert copilot.build_command_invocation("plan", "my args") == "/speckit-plan my args"
def test_build_command_invocation_default_mode(self):
copilot = self._make_copilot()
assert copilot.build_command_invocation("plan", "my args") == "my args"
assert copilot.build_command_invocation("plan") == ""
# -- Context section ---------------------------------------------------
def test_skills_setup_upserts_context_section(self, tmp_path):
copilot = self._make_copilot()
self._setup_skills(copilot, tmp_path)
ctx_path = tmp_path / copilot.context_file
assert ctx_path.exists()
content = ctx_path.read_text(encoding="utf-8")
assert "<!-- SPECKIT START -->" in content
assert "<!-- SPECKIT END -->" in content
# -- CLI integration test ---------------------------------------------
def test_init_with_integration_options_skills(self, tmp_path):
"""specify init --integration copilot --integration-options='--skills' scaffolds skills."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "copilot-skills"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot",
"--integration-options", "--skills",
"--script", "sh", "--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
skills_dir = project / ".github" / "skills"
assert skills_dir.is_dir(), "Skills directory was not created"
plan_skill = skills_dir / "speckit-plan" / "SKILL.md"
assert plan_skill.exists(), "speckit-plan/SKILL.md not found"
# Verify no default-mode artifacts
assert not (project / ".github" / "agents").exists()
assert not (project / ".github" / "prompts").exists()
assert not (project / ".vscode" / "settings.json").exists()
def test_complete_file_inventory_skills_sh(self, tmp_path):
"""Every file produced by specify init --integration copilot --integration-options='--skills' --script sh."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "inventory-skills-sh"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot",
"--integration-options", "--skills",
"--script", "sh", "--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file())
expected = sorted([
# Skill files
*[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS],
# Context file
".github/copilot-instructions.md",
# Integration metadata
".specify/init-options.json",
".specify/integration.json",
".specify/integrations/copilot.manifest.json",
".specify/integrations/speckit.manifest.json",
# Scripts (sh)
".specify/scripts/bash/check-prerequisites.sh",
".specify/scripts/bash/common.sh",
".specify/scripts/bash/create-new-feature.sh",
".specify/scripts/bash/setup-plan.sh",
# Templates
".specify/templates/checklist-template.md",
".specify/templates/constitution-template.md",
".specify/templates/plan-template.md",
".specify/templates/spec-template.md",
".specify/templates/tasks-template.md",
".specify/memory/constitution.md",
# Bundled workflow
".specify/workflows/speckit/workflow.yml",
".specify/workflows/workflow-registry.json",
])
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)
# -- Singleton leak: _skills_mode must reset --------------------------
def test_skills_mode_resets_on_default_setup(self, tmp_path):
"""setup() with skills=True then without must reset _skills_mode."""
copilot = self._make_copilot()
# First call: skills mode
(tmp_path / "proj1").mkdir()
m1 = IntegrationManifest("copilot", tmp_path / "proj1")
copilot.setup(tmp_path / "proj1", m1, parsed_options={"skills": True})
assert copilot._skills_mode is True
# Second call: default mode (no skills option)
(tmp_path / "proj2").mkdir()
m2 = IntegrationManifest("copilot", tmp_path / "proj2")
copilot.setup(tmp_path / "proj2", m2)
assert copilot._skills_mode is False
# build_command_invocation must use default (dotted) mode
assert copilot.build_command_invocation("plan", "args") == "args"
# -- Auto-detection must ignore unrelated .github/skills/ -------------
def test_dispatch_ignores_unrelated_skills_directory(self, tmp_path):
"""dispatch_command() must not treat unrelated .github/skills/ as skills mode."""
copilot = self._make_copilot()
# Create a .github/skills/ with non-speckit content (e.g. GitHub Skills training)
unrelated = tmp_path / ".github" / "skills" / "introduction-to-github"
unrelated.mkdir(parents=True)
(unrelated / "README.md").write_text("# GitHub Skills training\n")
# Should NOT detect skills mode — cli_args should contain --agent
import unittest.mock as mock
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
copilot.dispatch_command("plan", "my args", project_root=tmp_path, stream=False)
call_args = mock_run.call_args[0][0]
assert "--agent" in call_args, (
f"Expected --agent in cli_args but got: {call_args}"
)
assert "speckit.plan" in call_args
def test_dispatch_detects_speckit_skills_layout(self, tmp_path):
"""dispatch_command() detects speckit-*/SKILL.md as skills mode."""
copilot = self._make_copilot()
skill_dir = tmp_path / ".github" / "skills" / "speckit-plan"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("---\nname: speckit-plan\n---\n")
import unittest.mock as mock
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
copilot.dispatch_command("plan", "my args", project_root=tmp_path, stream=False)
call_args = mock_run.call_args[0][0]
assert "--agent" not in call_args, (
f"Skills mode should not use --agent, got: {call_args}"
)
prompt = call_args[call_args.index("-p") + 1]
assert "/speckit-plan" in prompt, (
f"Skills mode prompt should invoke /speckit-plan, got: {prompt}"
)
assert "my args" in prompt, (
f"Skills mode prompt should preserve user args, got: {prompt}"
)
# -- Next-steps display for Copilot skills mode -----------------------
def test_init_skills_next_steps_show_skill_syntax(self, tmp_path):
"""specify init --integration copilot --integration-options='--skills' shows /speckit-plan not /speckit.plan."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "copilot-nextsteps"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot",
"--integration-options", "--skills",
"--script", "sh", "--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
# Skills mode should show /speckit-plan (hyphenated)
assert "/speckit-plan" in result.output, (
f"Expected /speckit-plan in next steps but got:\n{result.output}"
)
# Must NOT show the dotted /speckit.plan form
assert "/speckit.plan" not in result.output, (
f"Should not show /speckit.plan in skills mode:\n{result.output}"
)

File diff suppressed because it is too large Load Diff

View File

@@ -100,25 +100,12 @@ class TestInstalledVersion:
def test_invalid_metadata_error_returns_unknown(self): def test_invalid_metadata_error_returns_unknown(self):
invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None) invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None)
if invalid_metadata_error is None: if invalid_metadata_error is None:
# Python versions without InvalidMetadataError: simulate with a pytest.skip("InvalidMetadataError is not available on this Python version")
# custom exception to verify the guarded except path works. with patch(
class _FakeInvalidMetadataError(Exception): "importlib.metadata.version",
pass side_effect=invalid_metadata_error("bad metadata"),
invalid_metadata_error = _FakeInvalidMetadataError ):
# Patch the attribute onto importlib.metadata so the production assert _get_installed_version() == "unknown"
# getattr() finds it during this test.
with patch.object(importlib.metadata, "InvalidMetadataError", invalid_metadata_error, create=True):
with patch(
"importlib.metadata.version",
side_effect=invalid_metadata_error("bad metadata"),
):
assert _get_installed_version() == "unknown"
else:
with patch(
"importlib.metadata.version",
side_effect=invalid_metadata_error("bad metadata"),
):
assert _get_installed_version() == "unknown"
class TestNormalizeTag: class TestNormalizeTag: