mirror of
https://github.com/github/spec-kit.git
synced 2026-07-08 23:41:46 +08:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f1be37039 | ||
|
|
ad62357015 | ||
|
|
57a518a583 | ||
|
|
db81a719a4 | ||
|
|
6d25d869b3 | ||
|
|
9307093d8a | ||
|
|
5a678c552e | ||
|
|
5a50b75adb | ||
|
|
0a8f31ef18 | ||
|
|
cec63d34e3 |
28
CHANGELOG.md
28
CHANGELOG.md
@@ -2,6 +2,34 @@
|
|||||||
|
|
||||||
<!-- insert new changelog below this comment -->
|
<!-- insert new changelog below this comment -->
|
||||||
|
|
||||||
|
## [0.8.17] - 2026-05-28
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- docs: consolidate Community sections in README (#2736)
|
||||||
|
- Fix shared script command hints for integration separators (#2627)
|
||||||
|
- docs: update security-governance preset to v0.4.0 (#2703)
|
||||||
|
- feat(agy): enhance Google Antigravity CLI integration (#2689)
|
||||||
|
- Fix --dev extension agent symlinks (#2554)
|
||||||
|
- Share skills hook note post-processing (#2679)
|
||||||
|
- feat: add Hermes Agent integration (with review fixes) (#2651)
|
||||||
|
- Update Superpowers Implementation Bridge to v0.7.0 (#2732)
|
||||||
|
- chore: release 0.8.16, begin 0.8.17.dev0 development (#2729)
|
||||||
|
|
||||||
|
## [0.8.16] - 2026-05-27
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- docs: update landing page stats and branch naming convention (#2727)
|
||||||
|
- feat(workflows): expose {{ context.run_id }} template variable (#2664)
|
||||||
|
- fix: resolve __SPECKIT_COMMAND_*__ refs in preset skill rendering (#2717) (#2718)
|
||||||
|
- Add Workflow Preset to community catalog (#2725)
|
||||||
|
- fix: paths-only skips branch validation, setup-plan preserves existing plan (#2672)
|
||||||
|
- docs: fix broken pipx homepage URLs to point to pipx.pypa.io (#2670)
|
||||||
|
- Update Architecture Guard extension to v1.8.9 (#2723)
|
||||||
|
- Re-validate spec quality checklist after clarify updates spec (#2715)
|
||||||
|
- chore: release 0.8.15, begin 0.8.16.dev0 development (#2722)
|
||||||
|
|
||||||
## [0.8.15] - 2026-05-27
|
## [0.8.15] - 2026-05-27
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
37
README.md
37
README.md
@@ -22,10 +22,7 @@
|
|||||||
- [🤔 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](#-community)
|
||||||
- [🎨 Community Presets](#-community-presets)
|
|
||||||
- [🚶 Community Walkthroughs](#-community-walkthroughs)
|
|
||||||
- [🛠️ Community Friends](#️-community-friends)
|
|
||||||
- [🤖 Supported AI Coding Agent Integrations](#-supported-ai-coding-agent-integrations)
|
- [🤖 Supported AI Coding Agent Integrations](#-supported-ai-coding-agent-integrations)
|
||||||
- [🔧 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)
|
||||||
@@ -112,31 +109,19 @@ 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
|
## 🌍 Community
|
||||||
|
|
||||||
Community-contributed extensions add new commands, hooks, and capabilities to Spec Kit. See the full list on the [Community Extensions](https://github.github.io/spec-kit/community/extensions.html) page.
|
Explore community-contributed resources on the [Spec Kit docs site](https://github.github.io/spec-kit/):
|
||||||
|
|
||||||
|
- [Extensions](https://github.github.io/spec-kit/community/extensions.html) — commands, hooks, and capabilities
|
||||||
|
- [Presets](https://github.github.io/spec-kit/community/presets.html) — template and terminology overrides
|
||||||
|
- [Walkthroughs](https://github.github.io/spec-kit/community/walkthroughs.html) — end-to-end SDD scenarios
|
||||||
|
- [Friends](https://github.github.io/spec-kit/community/friends.html) — projects that extend or build on Spec Kit
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Community extensions are independently created and maintained by their respective authors. Maintainers only verify that catalog entries are complete and correctly formatted — they do **not review, audit, endorse, or support the extension code itself**. Review extension source code before installation and use at your own discretion.
|
> Community contributions are independently created and maintained by their respective authors. Review source code before installation and use at your own discretion.
|
||||||
|
|
||||||
To submit your own extension, see the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md).
|
Want to contribute? See the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md) or the [Presets Publishing Guide](presets/PUBLISHING.md).
|
||||||
|
|
||||||
## 🎨 Community Presets
|
|
||||||
|
|
||||||
Community-contributed presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. See the full list on the [Community Presets](https://github.github.io/spec-kit/community/presets.html) page.
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> Community presets are third-party contributions and are not maintained by the Spec Kit team. Review them carefully before use, and see the docs page above for the full disclaimer.
|
|
||||||
|
|
||||||
To submit your own preset, see the [Presets Publishing Guide](presets/PUBLISHING.md).
|
|
||||||
|
|
||||||
## 🚶 Community Walkthroughs
|
|
||||||
|
|
||||||
See Spec-Driven Development in action across different scenarios with community-contributed walkthroughs; find the full list on the [Community Walkthroughs](https://github.github.io/spec-kit/community/walkthroughs.html) page.
|
|
||||||
|
|
||||||
## 🛠️ Community Friends
|
|
||||||
|
|
||||||
Community projects that extend, visualize, or build on Spec Kit. See the full list on the [Community Friends](https://github.github.io/spec-kit/community/friends.html) page.
|
|
||||||
|
|
||||||
## 🤖 Supported AI Coding Agent Integrations
|
## 🤖 Supported AI Coding Agent Integrations
|
||||||
|
|
||||||
@@ -206,7 +191,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 reference](https://github.github.io/spec-kit/reference/extensions.html) for the full command guide. Browse the [community extensions](#-community-extensions) above for what's available.
|
See the [Extensions reference](https://github.github.io/spec-kit/reference/extensions.html) for the full command guide. Browse the [community extensions](https://github.github.io/spec-kit/community/extensions.html) for what's available.
|
||||||
|
|
||||||
### Presets — Customize Existing Workflows
|
### Presets — Customize Existing Workflows
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
|||||||
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
|
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
|
||||||
| 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) |
|
| 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) |
|
||||||
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
|
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
|
||||||
| Security Governance | Adds secure development governance: memory-safe-language preference, secure code generation, NIST SSDF, CWE Top 25, OWASP ASVS, SBOM/AI-SBOM, VEX/SLSA, OpenSSF Scorecard, G7/BSI AI-SBOM target evidence, and EU CRA applicability | 12 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
|
| Security Governance | Adds secure development governance: memory-safe-language preference, language-specific secure-coding profiles, NIST SSDF, CWE Top 25, OWASP ASVS, SBOM/AI-SBOM, VEX/SLSA, OpenSSF Scorecard, G7/BSI AI-SBOM target evidence, and EU CRA applicability | 12 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
|
||||||
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy | 5 templates, 8 commands | — | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
|
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy | 5 templates, 8 commands | — | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
|
||||||
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |
|
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |
|
||||||
| VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) |
|
| VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) |
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ specify extension add <extension-name> --from https://github.com/org/spec-kit-ex
|
|||||||
|
|
||||||
🔍 **Browse and search community extensions on the [Community Extensions website](https://speckit-community.github.io/extensions/).**
|
🔍 **Browse and search community extensions on the [Community Extensions website](https://speckit-community.github.io/extensions/).**
|
||||||
|
|
||||||
See the [Community Extensions](../README.md#-community-extensions) section in the main README for the full list of available community-contributed extensions.
|
See the [Community Extensions](https://github.github.io/spec-kit/community/extensions.html) page for the full list of available community-contributed extensions.
|
||||||
|
|
||||||
For the raw catalog data, see [`catalog.community.json`](catalog.community.json).
|
For the raw catalog data, see [`catalog.community.json`](catalog.community.json).
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schema_version": "1.0",
|
"schema_version": "1.0",
|
||||||
"updated_at": "2026-05-27T00:00:00Z",
|
"updated_at": "2026-05-28T00:00:00Z",
|
||||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||||
"extensions": {
|
"extensions": {
|
||||||
"aide": {
|
"aide": {
|
||||||
@@ -2650,8 +2650,8 @@
|
|||||||
"id": "speckit-superpowers-bridge",
|
"id": "speckit-superpowers-bridge",
|
||||||
"description": "Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent.",
|
"description": "Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent.",
|
||||||
"author": "lihan3238",
|
"author": "lihan3238",
|
||||||
"version": "0.5.0",
|
"version": "0.7.0",
|
||||||
"download_url": "https://github.com/lihan3238/speckit-superpowers-bridge/releases/download/v0.5.0/speckit-superpowers-bridge-v0.5.0.zip",
|
"download_url": "https://github.com/lihan3238/speckit-superpowers-bridge/releases/download/v0.7.0/speckit-superpowers-bridge-v0.7.0.zip",
|
||||||
"repository": "https://github.com/lihan3238/speckit-superpowers-bridge",
|
"repository": "https://github.com/lihan3238/speckit-superpowers-bridge",
|
||||||
"homepage": "https://github.com/lihan3238/speckit-superpowers-bridge",
|
"homepage": "https://github.com/lihan3238/speckit-superpowers-bridge",
|
||||||
"documentation": "https://github.com/lihan3238/speckit-superpowers-bridge#readme",
|
"documentation": "https://github.com/lihan3238/speckit-superpowers-bridge#readme",
|
||||||
@@ -2692,7 +2692,7 @@
|
|||||||
"downloads": 0,
|
"downloads": 0,
|
||||||
"stars": 0,
|
"stars": 0,
|
||||||
"created_at": "2026-05-15T00:00:00Z",
|
"created_at": "2026-05-15T00:00:00Z",
|
||||||
"updated_at": "2026-05-20T00:00:00Z"
|
"updated_at": "2026-05-28T00:00:00Z"
|
||||||
},
|
},
|
||||||
"speckit-utils": {
|
"speckit-utils": {
|
||||||
"name": "SDD Utilities",
|
"name": "SDD Utilities",
|
||||||
|
|||||||
@@ -272,6 +272,15 @@
|
|||||||
"author": "spec-kit-core",
|
"author": "spec-kit-core",
|
||||||
"repository": "https://github.com/github/spec-kit",
|
"repository": "https://github.com/github/spec-kit",
|
||||||
"tags": ["cli"]
|
"tags": ["cli"]
|
||||||
|
},
|
||||||
|
"hermes": {
|
||||||
|
"id": "hermes",
|
||||||
|
"name": "Hermes Agent",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Hermes Agent skills-based integration by Nous Research",
|
||||||
|
"author": "spec-kit-core",
|
||||||
|
"repository": "https://github.com/github/spec-kit",
|
||||||
|
"tags": ["cli", "skills"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -472,11 +472,11 @@
|
|||||||
"security-governance": {
|
"security-governance": {
|
||||||
"name": "Security Governance",
|
"name": "Security Governance",
|
||||||
"id": "security-governance",
|
"id": "security-governance",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"description": "Adds memory-safe-language preference, secure code generation, ASVS verification, SBOM/AI-SBOM supply-chain transparency, and EU Cyber Resilience Act awareness.",
|
"description": "Adds memory-safe-language preference, language-specific secure coding profiles, ASVS verification, SBOM/AI-SBOM supply-chain transparency, and EU Cyber Resilience Act awareness.",
|
||||||
"author": "Thorsten Hindermann",
|
"author": "Thorsten Hindermann",
|
||||||
"repository": "https://github.com/hindermath/spec-kit-preset-security-governance",
|
"repository": "https://github.com/hindermath/spec-kit-preset-security-governance",
|
||||||
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.3.0.zip",
|
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.4.0.zip",
|
||||||
"homepage": "https://github.com/hindermath/spec-kit-preset-security-governance",
|
"homepage": "https://github.com/hindermath/spec-kit-preset-security-governance",
|
||||||
"documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/main/README.md",
|
"documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/main/README.md",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -499,12 +499,20 @@
|
|||||||
"vex",
|
"vex",
|
||||||
"slsa",
|
"slsa",
|
||||||
"cwe-top-25",
|
"cwe-top-25",
|
||||||
|
"secure-coding",
|
||||||
|
"rust",
|
||||||
|
"go",
|
||||||
|
"swift",
|
||||||
|
"java",
|
||||||
|
"kotlin",
|
||||||
|
"python",
|
||||||
|
"typescript",
|
||||||
"g7",
|
"g7",
|
||||||
"bsi",
|
"bsi",
|
||||||
"cra"
|
"cra"
|
||||||
],
|
],
|
||||||
"created_at": "2026-04-27T00:00:00Z",
|
"created_at": "2026-04-27T00:00:00Z",
|
||||||
"updated_at": "2026-05-22T00:00:00Z"
|
"updated_at": "2026-05-26T00:00:00Z"
|
||||||
},
|
},
|
||||||
"spec2cloud": {
|
"spec2cloud": {
|
||||||
"name": "Spec2Cloud",
|
"name": "Spec2Cloud",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "specify-cli"
|
name = "specify-cli"
|
||||||
version = "0.8.16.dev0"
|
version = "0.8.17"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -117,20 +117,20 @@ check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
|||||||
# Validate required directories and files
|
# Validate required directories and files
|
||||||
if [[ ! -d "$FEATURE_DIR" ]]; then
|
if [[ ! -d "$FEATURE_DIR" ]]; then
|
||||||
echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2
|
echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2
|
||||||
echo "Run /speckit.specify first to create the feature structure." >&2
|
echo "Run __SPECKIT_COMMAND_SPECIFY__ first to create the feature structure." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -f "$IMPL_PLAN" ]]; then
|
if [[ ! -f "$IMPL_PLAN" ]]; then
|
||||||
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
|
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
|
||||||
echo "Run /speckit.plan first to create the implementation plan." >&2
|
echo "Run __SPECKIT_COMMAND_PLAN__ first to create the implementation plan." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check for tasks.md if required
|
# Check for tasks.md if required
|
||||||
if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then
|
if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then
|
||||||
echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2
|
echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2
|
||||||
echo "Run /speckit.tasks first to create the task list." >&2
|
echo "Run __SPECKIT_COMMAND_TASKS__ first to create the task list." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ read_feature_json_feature_directory() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Returns 0 when .specify/feature.json lists feature_directory that exists as a directory
|
# Returns 0 when .specify/feature.json lists feature_directory that exists as a directory
|
||||||
# and matches the resolved active FEATURE_DIR (so /speckit.plan can skip git branch pattern checks).
|
# and matches the resolved active FEATURE_DIR (so __SPECKIT_COMMAND_PLAN__ can skip git branch pattern checks).
|
||||||
# Delegates parsing to read_feature_json_feature_directory, which is safe under `set -e`.
|
# Delegates parsing to read_feature_json_feature_directory, which is safe under `set -e`.
|
||||||
feature_json_matches_feature_dir() {
|
feature_json_matches_feature_dir() {
|
||||||
local repo_root="$1"
|
local repo_root="$1"
|
||||||
@@ -262,7 +262,7 @@ get_feature_paths() {
|
|||||||
|
|
||||||
# Resolve feature directory. Priority:
|
# Resolve feature directory. Priority:
|
||||||
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
|
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
|
||||||
# 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify)
|
# 2. .specify/feature.json "feature_directory" key (persisted by __SPECKIT_COMMAND_SPECIFY__)
|
||||||
# 3. Branch-name-based prefix lookup (legacy fallback)
|
# 3. Branch-name-based prefix lookup (legacy fallback)
|
||||||
local feature_dir
|
local feature_dir
|
||||||
if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then
|
if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then
|
||||||
@@ -642,4 +642,3 @@ except Exception:
|
|||||||
printf '%s' "$content"
|
printf '%s' "$content"
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,13 +35,13 @@ fi
|
|||||||
|
|
||||||
if [[ ! -f "$IMPL_PLAN" ]]; then
|
if [[ ! -f "$IMPL_PLAN" ]]; then
|
||||||
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
|
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
|
||||||
echo "Run /speckit.plan first to create the implementation plan." >&2
|
echo "Run __SPECKIT_COMMAND_PLAN__ first to create the implementation plan." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -f "$FEATURE_SPEC" ]]; then
|
if [[ ! -f "$FEATURE_SPEC" ]]; then
|
||||||
echo "ERROR: spec.md not found in $FEATURE_DIR" >&2
|
echo "ERROR: spec.md not found in $FEATURE_DIR" >&2
|
||||||
echo "Run /speckit.specify first to create the feature structure." >&2
|
echo "Run __SPECKIT_COMMAND_SPECIFY__ first to create the feature structure." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -89,20 +89,20 @@ if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GI
|
|||||||
# Validate required directories and files
|
# Validate required directories and files
|
||||||
if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
|
if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
|
||||||
Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)"
|
Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)"
|
||||||
Write-Output "Run /speckit.specify first to create the feature structure."
|
Write-Output "Run __SPECKIT_COMMAND_SPECIFY__ first to create the feature structure."
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
|
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
|
||||||
Write-Output "ERROR: plan.md not found in $($paths.FEATURE_DIR)"
|
Write-Output "ERROR: plan.md not found in $($paths.FEATURE_DIR)"
|
||||||
Write-Output "Run /speckit.plan first to create the implementation plan."
|
Write-Output "Run __SPECKIT_COMMAND_PLAN__ first to create the implementation plan."
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check for tasks.md if required
|
# Check for tasks.md if required
|
||||||
if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) {
|
if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) {
|
||||||
Write-Output "ERROR: tasks.md not found in $($paths.FEATURE_DIR)"
|
Write-Output "ERROR: tasks.md not found in $($paths.FEATURE_DIR)"
|
||||||
Write-Output "Run /speckit.tasks first to create the task list."
|
Write-Output "Run __SPECKIT_COMMAND_TASKS__ first to create the task list."
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ function Test-FeatureBranch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# True when .specify/feature.json pins an existing feature directory that matches the
|
# True when .specify/feature.json pins an existing feature directory that matches the
|
||||||
# active FEATURE_DIR from Get-FeaturePathsEnv (so /speckit.plan can skip git branch pattern checks).
|
# active FEATURE_DIR from Get-FeaturePathsEnv (so __SPECKIT_COMMAND_PLAN__ can skip git branch pattern checks).
|
||||||
function Test-FeatureJsonMatchesFeatureDir {
|
function Test-FeatureJsonMatchesFeatureDir {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory = $true)][string]$RepoRoot,
|
[Parameter(Mandatory = $true)][string]$RepoRoot,
|
||||||
@@ -288,7 +288,7 @@ function Get-FeaturePathsEnv {
|
|||||||
|
|
||||||
# Resolve feature directory. Priority:
|
# Resolve feature directory. Priority:
|
||||||
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
|
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
|
||||||
# 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify)
|
# 2. .specify/feature.json "feature_directory" key (persisted by __SPECKIT_COMMAND_SPECIFY__)
|
||||||
# 3. Branch-name-based prefix lookup (same as scripts/bash/common.sh)
|
# 3. Branch-name-based prefix lookup (same as scripts/bash/common.sh)
|
||||||
$featureJson = Join-Path $repoRoot '.specify/feature.json'
|
$featureJson = Join-Path $repoRoot '.specify/feature.json'
|
||||||
if ($env:SPECIFY_FEATURE_DIRECTORY) {
|
if ($env:SPECIFY_FEATURE_DIRECTORY) {
|
||||||
@@ -640,4 +640,4 @@ except Exception:
|
|||||||
}
|
}
|
||||||
|
|
||||||
return $content
|
return $content
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,13 +28,13 @@ if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFe
|
|||||||
|
|
||||||
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
|
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
|
||||||
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
|
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
|
||||||
[Console]::Error.WriteLine("Run /speckit.plan first to create the implementation plan.")
|
[Console]::Error.WriteLine("Run __SPECKIT_COMMAND_PLAN__ first to create the implementation plan.")
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) {
|
if (-not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) {
|
||||||
[Console]::Error.WriteLine("ERROR: spec.md not found in $($paths.FEATURE_DIR)")
|
[Console]::Error.WriteLine("ERROR: spec.md not found in $($paths.FEATURE_DIR)")
|
||||||
[Console]::Error.WriteLine("Run /speckit.specify first to create the feature structure.")
|
[Console]::Error.WriteLine("Run __SPECKIT_COMMAND_SPECIFY__ first to create the feature structure.")
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -161,9 +161,9 @@ def _install_shared_infra(
|
|||||||
``bash`` when *script_type* is ``"sh"`` and ``powershell`` when it is
|
``bash`` when *script_type* is ``"sh"`` and ``powershell`` when it is
|
||||||
``"ps"``. Tracks all installed files in ``speckit.manifest.json``.
|
``"ps"``. Tracks all installed files in ``speckit.manifest.json``.
|
||||||
|
|
||||||
Page templates are processed to resolve ``__SPECKIT_COMMAND_<NAME>__``
|
Shared scripts and page templates are processed to resolve
|
||||||
placeholders using *invoke_separator* (``"."`` for markdown agents,
|
``__SPECKIT_COMMAND_<NAME>__`` placeholders using *invoke_separator*
|
||||||
``"-"`` for skills agents).
|
(``"."`` for markdown agents, ``"-"`` for skills agents).
|
||||||
|
|
||||||
Overwrite policy:
|
Overwrite policy:
|
||||||
|
|
||||||
@@ -741,6 +741,7 @@ def _set_default_integration(
|
|||||||
parsed_options: dict[str, Any] | None = None,
|
parsed_options: dict[str, Any] | None = None,
|
||||||
refresh_templates: bool = True,
|
refresh_templates: bool = True,
|
||||||
refresh_templates_force: bool = False,
|
refresh_templates_force: bool = False,
|
||||||
|
refresh_hint: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Persist *key* as default and align active runtime metadata."""
|
"""Persist *key* as default and align active runtime metadata."""
|
||||||
resolved_script = _resolve_integration_script_type(project_root, state, key, script_type)
|
resolved_script = _resolve_integration_script_type(project_root, state, key, script_type)
|
||||||
@@ -755,16 +756,19 @@ def _set_default_integration(
|
|||||||
|
|
||||||
if refresh_templates:
|
if refresh_templates:
|
||||||
try:
|
try:
|
||||||
_refresh_shared_templates(
|
_install_shared_infra(
|
||||||
project_root,
|
project_root,
|
||||||
|
resolved_script,
|
||||||
invoke_separator=_invoke_separator_for_integration(
|
invoke_separator=_invoke_separator_for_integration(
|
||||||
integration, {"integration_settings": settings}, key, parsed_options
|
integration, {"integration_settings": settings}, key, parsed_options
|
||||||
),
|
),
|
||||||
force=refresh_templates_force,
|
force=refresh_templates_force,
|
||||||
|
refresh_managed=True,
|
||||||
|
refresh_hint=refresh_hint,
|
||||||
)
|
)
|
||||||
except (ValueError, OSError) as exc:
|
except (ValueError, OSError) as exc:
|
||||||
raise _SharedTemplateRefreshError(
|
raise _SharedTemplateRefreshError(
|
||||||
f"Failed to refresh shared templates for '{key}': {exc}"
|
f"Failed to refresh shared infrastructure for '{key}': {exc}"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
_write_integration_json(project_root, key, installed_keys, settings)
|
_write_integration_json(project_root, key, installed_keys, settings)
|
||||||
@@ -1115,7 +1119,7 @@ def _update_init_options_for_integration(
|
|||||||
@integration_app.command("use")
|
@integration_app.command("use")
|
||||||
def integration_use(
|
def integration_use(
|
||||||
key: str = typer.Argument(help="Installed integration key to make the default"),
|
key: str = typer.Argument(help="Installed integration key to make the default"),
|
||||||
force: bool = typer.Option(False, "--force", help="Overwrite managed shared templates while changing the default"),
|
force: bool = typer.Option(False, "--force", help="Overwrite existing shared infrastructure files, including customizations, while changing the default"),
|
||||||
):
|
):
|
||||||
"""Set the default integration without uninstalling other integrations."""
|
"""Set the default integration without uninstalling other integrations."""
|
||||||
from .integrations import get_integration
|
from .integrations import get_integration
|
||||||
@@ -1146,6 +1150,10 @@ def integration_use(
|
|||||||
raw_options=raw_options,
|
raw_options=raw_options,
|
||||||
parsed_options=parsed_options,
|
parsed_options=parsed_options,
|
||||||
refresh_templates_force=force,
|
refresh_templates_force=force,
|
||||||
|
refresh_hint=(
|
||||||
|
"To overwrite customizations, re-run with "
|
||||||
|
f"[cyan]specify integration use {key} --force[/cyan]."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
console.print(f"[green]✓[/green] Default integration set to [bold]{key}[/bold].")
|
console.print(f"[green]✓[/green] Default integration set to [bold]{key}[/bold].")
|
||||||
|
|
||||||
@@ -1218,11 +1226,14 @@ def integration_uninstall(
|
|||||||
console.print(f"[dim]Details:[/dim] {exc}")
|
console.print(f"[dim]Details:[/dim] {exc}")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
removed, skipped = manifest.uninstall(project_root, force=force)
|
if not integration:
|
||||||
|
console.print(
|
||||||
# Remove managed context section from the agent context file
|
f"[yellow]Warning:[/yellow] Integration '{key}' not found "
|
||||||
if integration:
|
"in registry. Falling back to manifest-based cleanup."
|
||||||
integration.remove_context_section(project_root)
|
)
|
||||||
|
removed, skipped = manifest.uninstall(project_root, force=force)
|
||||||
|
else:
|
||||||
|
removed, skipped = integration.teardown(project_root, manifest, force=force)
|
||||||
|
|
||||||
remaining = [installed for installed in installed_keys if installed != key]
|
remaining = [installed for installed in installed_keys if installed != key]
|
||||||
new_default = default_key if default_key != key else (remaining[0] if remaining else None)
|
new_default = default_key if default_key != key else (remaining[0] if remaining else None)
|
||||||
@@ -1312,7 +1323,7 @@ def integration_switch(
|
|||||||
)
|
)
|
||||||
console.print(
|
console.print(
|
||||||
f"\n[green]✓[/green] Default integration remains [bold]{target}[/bold]; "
|
f"\n[green]✓[/green] Default integration remains [bold]{target}[/bold]; "
|
||||||
"managed shared templates refreshed."
|
"shared infrastructure refreshed."
|
||||||
)
|
)
|
||||||
raise typer.Exit(0)
|
raise typer.Exit(0)
|
||||||
console.print(f"[yellow]Integration '{target}' is already the default integration. Nothing to switch.[/yellow]")
|
console.print(f"[yellow]Integration '{target}' is already the default integration. Nothing to switch.[/yellow]")
|
||||||
@@ -1364,8 +1375,9 @@ def integration_switch(
|
|||||||
f"run [cyan]specify integration uninstall {installed_key}[/cyan], then retry."
|
f"run [cyan]specify integration uninstall {installed_key}[/cyan], then retry."
|
||||||
)
|
)
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
removed, skipped = old_manifest.uninstall(project_root, force=force)
|
removed, skipped = current_integration.teardown(
|
||||||
current_integration.remove_context_section(project_root)
|
project_root, old_manifest, force=force,
|
||||||
|
)
|
||||||
if removed:
|
if removed:
|
||||||
console.print(f" Removed {len(removed)} file(s)")
|
console.print(f" Removed {len(removed)} file(s)")
|
||||||
if skipped:
|
if skipped:
|
||||||
@@ -1667,16 +1679,18 @@ def integration_upgrade(
|
|||||||
)
|
)
|
||||||
if installed_key == key:
|
if installed_key == key:
|
||||||
try:
|
try:
|
||||||
_refresh_shared_templates(
|
_install_shared_infra(
|
||||||
project_root,
|
project_root,
|
||||||
|
selected_script,
|
||||||
invoke_separator=_invoke_separator_for_integration(
|
invoke_separator=_invoke_separator_for_integration(
|
||||||
integration, {"integration_settings": settings}, key, parsed_options
|
integration, {"integration_settings": settings}, key, parsed_options
|
||||||
),
|
),
|
||||||
force=force,
|
force=force,
|
||||||
|
refresh_managed=True,
|
||||||
)
|
)
|
||||||
except (ValueError, OSError) as exc:
|
except (ValueError, OSError) as exc:
|
||||||
raise _SharedTemplateRefreshError(
|
raise _SharedTemplateRefreshError(
|
||||||
f"Failed to refresh shared templates for '{key}': {exc}"
|
f"Failed to refresh shared infrastructure for '{key}': {exc}"
|
||||||
) from exc
|
) from exc
|
||||||
new_manifest.save()
|
new_manifest.save()
|
||||||
_write_integration_json(project_root, installed_key, installed_keys, settings)
|
_write_integration_json(project_root, installed_key, installed_keys, settings)
|
||||||
@@ -2967,7 +2981,12 @@ def extension_add(
|
|||||||
console.print(f"[red]Error:[/red] No extension.yml found in {source_path}")
|
console.print(f"[red]Error:[/red] No extension.yml found in {source_path}")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
manifest = manager.install_from_directory(source_path, speckit_version, priority=priority)
|
manifest = manager.install_from_directory(
|
||||||
|
source_path,
|
||||||
|
speckit_version,
|
||||||
|
priority=priority,
|
||||||
|
link_commands=True,
|
||||||
|
)
|
||||||
|
|
||||||
elif from_url:
|
elif from_url:
|
||||||
# Install from URL (ZIP file)
|
# Install from URL (ZIP file)
|
||||||
@@ -3601,7 +3620,9 @@ def extension_update(
|
|||||||
if agent_name not in registrar.AGENT_CONFIGS:
|
if agent_name not in registrar.AGENT_CONFIGS:
|
||||||
continue
|
continue
|
||||||
agent_config = registrar.AGENT_CONFIGS[agent_name]
|
agent_config = registrar.AGENT_CONFIGS[agent_name]
|
||||||
commands_dir = project_root / agent_config["dir"]
|
commands_dir = _AgentReg._resolve_agent_dir(
|
||||||
|
agent_name, agent_config, project_root
|
||||||
|
)
|
||||||
|
|
||||||
for cmd_name in cmd_names:
|
for cmd_name in cmd_names:
|
||||||
output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config)
|
output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config)
|
||||||
@@ -3762,7 +3783,9 @@ def extension_update(
|
|||||||
if agent_name not in registrar.AGENT_CONFIGS:
|
if agent_name not in registrar.AGENT_CONFIGS:
|
||||||
continue
|
continue
|
||||||
agent_config = registrar.AGENT_CONFIGS[agent_name]
|
agent_config = registrar.AGENT_CONFIGS[agent_name]
|
||||||
commands_dir = project_root / agent_config["dir"]
|
commands_dir = _AgentReg._resolve_agent_dir(
|
||||||
|
agent_name, agent_config, project_root
|
||||||
|
)
|
||||||
|
|
||||||
for cmd_name in cmd_names:
|
for cmd_name in cmd_names:
|
||||||
output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config)
|
output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config)
|
||||||
|
|||||||
@@ -439,6 +439,7 @@ class CommandRegistrar:
|
|||||||
project_root: Path,
|
project_root: Path,
|
||||||
context_note: str = None,
|
context_note: str = None,
|
||||||
_resolved_dir: Path = None,
|
_resolved_dir: Path = None,
|
||||||
|
link_outputs: bool = False,
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""Register commands for a specific agent.
|
"""Register commands for a specific agent.
|
||||||
|
|
||||||
@@ -453,6 +454,9 @@ class CommandRegistrar:
|
|||||||
only — avoids a second ``_resolve_agent_dir`` call and
|
only — avoids a second ``_resolve_agent_dir`` call and
|
||||||
duplicate deprecation warnings when invoked from
|
duplicate deprecation warnings when invoked from
|
||||||
``register_commands_for_all_agents``).
|
``register_commands_for_all_agents``).
|
||||||
|
link_outputs: If True, write rendered output to a source-local
|
||||||
|
dev cache and symlink the agent command file to it. Falls back
|
||||||
|
to a normal file write when symlinks are unavailable.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of registered command names
|
List of registered command names
|
||||||
@@ -559,7 +563,15 @@ class CommandRegistrar:
|
|||||||
dest_file = commands_dir / f"{output_name}{agent_config['extension']}"
|
dest_file = commands_dir / f"{output_name}{agent_config['extension']}"
|
||||||
self._ensure_inside(dest_file, commands_dir)
|
self._ensure_inside(dest_file, commands_dir)
|
||||||
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
dest_file.write_text(output, encoding="utf-8")
|
self._write_registered_output(
|
||||||
|
dest_file,
|
||||||
|
output,
|
||||||
|
source_dir,
|
||||||
|
agent_name,
|
||||||
|
output_name,
|
||||||
|
agent_config["extension"],
|
||||||
|
link_outputs,
|
||||||
|
)
|
||||||
|
|
||||||
if agent_name == "copilot":
|
if agent_name == "copilot":
|
||||||
self.write_copilot_prompt(project_root, cmd_name)
|
self.write_copilot_prompt(project_root, cmd_name)
|
||||||
@@ -625,13 +637,56 @@ class CommandRegistrar:
|
|||||||
)
|
)
|
||||||
self._ensure_inside(alias_file, commands_dir)
|
self._ensure_inside(alias_file, commands_dir)
|
||||||
alias_file.parent.mkdir(parents=True, exist_ok=True)
|
alias_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
alias_file.write_text(alias_output, encoding="utf-8")
|
self._write_registered_output(
|
||||||
|
alias_file,
|
||||||
|
alias_output,
|
||||||
|
source_dir,
|
||||||
|
agent_name,
|
||||||
|
alias_output_name,
|
||||||
|
agent_config["extension"],
|
||||||
|
link_outputs,
|
||||||
|
)
|
||||||
if agent_name == "copilot":
|
if agent_name == "copilot":
|
||||||
self.write_copilot_prompt(project_root, alias)
|
self.write_copilot_prompt(project_root, alias)
|
||||||
registered.append(alias)
|
registered.append(alias)
|
||||||
|
|
||||||
return registered
|
return registered
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write_registered_output(
|
||||||
|
dest_file: Path,
|
||||||
|
content: str,
|
||||||
|
source_dir: Path,
|
||||||
|
agent_name: str,
|
||||||
|
output_name: str,
|
||||||
|
extension: str,
|
||||||
|
link_outputs: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Write a rendered agent artifact, optionally as a dev-mode symlink."""
|
||||||
|
if not link_outputs:
|
||||||
|
dest_file.write_text(content, encoding="utf-8")
|
||||||
|
return
|
||||||
|
|
||||||
|
rel_output = Path(f"{output_name}{extension}")
|
||||||
|
cache_root = source_dir / ".specify-dev" / "agent-commands" / agent_name
|
||||||
|
cache_file = cache_root / rel_output
|
||||||
|
CommandRegistrar._ensure_inside(cache_file, cache_root)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
cache_file.write_text(content, encoding="utf-8")
|
||||||
|
if dest_file.exists() or dest_file.is_symlink():
|
||||||
|
dest_file.unlink()
|
||||||
|
target = os.path.relpath(cache_file, dest_file.parent)
|
||||||
|
os.symlink(target, dest_file)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
# Windows often requires Developer Mode or admin privileges for
|
||||||
|
# symlinks, and relpath can fail across drives. Keep dev installs
|
||||||
|
# functional by falling back to a copy.
|
||||||
|
if dest_file.is_symlink():
|
||||||
|
dest_file.unlink()
|
||||||
|
dest_file.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def write_copilot_prompt(project_root: Path, cmd_name: str) -> None:
|
def write_copilot_prompt(project_root: Path, cmd_name: str) -> None:
|
||||||
"""Generate a companion .prompt.md file for a Copilot agent command.
|
"""Generate a companion .prompt.md file for a Copilot agent command.
|
||||||
@@ -654,15 +709,28 @@ class CommandRegistrar:
|
|||||||
) -> Path:
|
) -> Path:
|
||||||
"""Return the agent command directory, falling back to legacy_dir.
|
"""Return the agent command directory, falling back to legacy_dir.
|
||||||
|
|
||||||
When the canonical directory (``agent_config["dir"]``) does not
|
Supports project-relative paths (e.g. ``.claude/skills/``),
|
||||||
exist but a ``legacy_dir`` is configured and present on disk,
|
home-relative paths (e.g. ``~/.hermes/skills``), and absolute
|
||||||
returns the legacy path and emits a deprecation warning advising
|
paths — the ``agent_config["dir"]`` value is resolved verbatim
|
||||||
the user to upgrade.
|
when absolute or starting with ``~/``, or joined with
|
||||||
|
``project_root`` when relative.
|
||||||
|
|
||||||
|
When the canonical directory does not exist but a ``legacy_dir``
|
||||||
|
is configured and present on disk, returns the legacy path and
|
||||||
|
emits a deprecation warning advising the user to upgrade.
|
||||||
|
|
||||||
Integrations that do not declare ``legacy_dir`` get the canonical
|
Integrations that do not declare ``legacy_dir`` get the canonical
|
||||||
path unconditionally — no fallback, no warning.
|
path unconditionally — no fallback, no warning.
|
||||||
"""
|
"""
|
||||||
agent_dir = project_root / agent_config["dir"]
|
dir_str = agent_config["dir"]
|
||||||
|
if dir_str.startswith("~"):
|
||||||
|
# Use Path.home() + remainder instead of expanduser() so tests
|
||||||
|
# that monkeypatch Path.home() can properly isolate the home dir.
|
||||||
|
# expanduser() uses OS env/user lookup and ignores monkeypatches.
|
||||||
|
agent_dir = Path.home() / dir_str[1:].lstrip("/")
|
||||||
|
else:
|
||||||
|
p = Path(dir_str)
|
||||||
|
agent_dir = p if p.is_absolute() else project_root / p
|
||||||
if not agent_dir.exists():
|
if not agent_dir.exists():
|
||||||
legacy = agent_config.get("legacy_dir")
|
legacy = agent_config.get("legacy_dir")
|
||||||
if legacy:
|
if legacy:
|
||||||
@@ -687,6 +755,7 @@ class CommandRegistrar:
|
|||||||
source_dir: Path,
|
source_dir: Path,
|
||||||
project_root: Path,
|
project_root: Path,
|
||||||
context_note: str = None,
|
context_note: str = None,
|
||||||
|
link_outputs: bool = False,
|
||||||
) -> Dict[str, List[str]]:
|
) -> Dict[str, List[str]]:
|
||||||
"""Register commands for all detected agents in the project.
|
"""Register commands for all detected agents in the project.
|
||||||
|
|
||||||
@@ -696,6 +765,8 @@ class CommandRegistrar:
|
|||||||
source_dir: Directory containing command source files
|
source_dir: Directory containing command source files
|
||||||
project_root: Path to project root
|
project_root: Path to project root
|
||||||
context_note: Custom context comment for markdown output
|
context_note: Custom context comment for markdown output
|
||||||
|
link_outputs: If True, create dev-mode symlinks for rendered
|
||||||
|
command files when supported by the OS.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary mapping agent names to list of registered commands
|
Dictionary mapping agent names to list of registered commands
|
||||||
@@ -704,6 +775,15 @@ class CommandRegistrar:
|
|||||||
|
|
||||||
self._ensure_configs()
|
self._ensure_configs()
|
||||||
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
||||||
|
# Check detect_dir first (project-local marker) if configured,
|
||||||
|
# falling back to the resolved dir for output. This prevents
|
||||||
|
# global dirs (e.g. ~/.hermes/skills) from causing false
|
||||||
|
# detection in every project.
|
||||||
|
detect_dir_str = agent_config.get("detect_dir")
|
||||||
|
if detect_dir_str:
|
||||||
|
detect_path = project_root / detect_dir_str
|
||||||
|
if not detect_path.exists():
|
||||||
|
continue
|
||||||
agent_dir = self._resolve_agent_dir(
|
agent_dir = self._resolve_agent_dir(
|
||||||
agent_name, agent_config, project_root,
|
agent_name, agent_config, project_root,
|
||||||
)
|
)
|
||||||
@@ -718,6 +798,7 @@ class CommandRegistrar:
|
|||||||
project_root,
|
project_root,
|
||||||
context_note=context_note,
|
context_note=context_note,
|
||||||
_resolved_dir=agent_dir,
|
_resolved_dir=agent_dir,
|
||||||
|
link_outputs=link_outputs,
|
||||||
)
|
)
|
||||||
if registered:
|
if registered:
|
||||||
results[agent_name] = registered
|
results[agent_name] = registered
|
||||||
@@ -733,6 +814,7 @@ class CommandRegistrar:
|
|||||||
source_dir: Path,
|
source_dir: Path,
|
||||||
project_root: Path,
|
project_root: Path,
|
||||||
context_note: Optional[str] = None,
|
context_note: Optional[str] = None,
|
||||||
|
link_outputs: bool = False,
|
||||||
) -> Dict[str, List[str]]:
|
) -> Dict[str, List[str]]:
|
||||||
"""Register commands for all non-skill agents in the project.
|
"""Register commands for all non-skill agents in the project.
|
||||||
|
|
||||||
@@ -746,6 +828,8 @@ class CommandRegistrar:
|
|||||||
source_dir: Directory containing command source files
|
source_dir: Directory containing command source files
|
||||||
project_root: Path to project root
|
project_root: Path to project root
|
||||||
context_note: Custom context comment for markdown output
|
context_note: Custom context comment for markdown output
|
||||||
|
link_outputs: If True, create dev-mode symlinks for rendered
|
||||||
|
command files when supported by the OS.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary mapping agent names to list of registered commands
|
Dictionary mapping agent names to list of registered commands
|
||||||
@@ -755,6 +839,11 @@ class CommandRegistrar:
|
|||||||
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
||||||
if agent_config.get("extension") == "/SKILL.md":
|
if agent_config.get("extension") == "/SKILL.md":
|
||||||
continue
|
continue
|
||||||
|
detect_dir_str = agent_config.get("detect_dir")
|
||||||
|
if detect_dir_str:
|
||||||
|
detect_path = project_root / detect_dir_str
|
||||||
|
if not detect_path.exists():
|
||||||
|
continue
|
||||||
agent_dir = self._resolve_agent_dir(
|
agent_dir = self._resolve_agent_dir(
|
||||||
agent_name, agent_config, project_root,
|
agent_name, agent_config, project_root,
|
||||||
)
|
)
|
||||||
@@ -768,6 +857,7 @@ class CommandRegistrar:
|
|||||||
project_root,
|
project_root,
|
||||||
context_note=context_note,
|
context_note=context_note,
|
||||||
_resolved_dir=agent_dir,
|
_resolved_dir=agent_dir,
|
||||||
|
link_outputs=link_outputs,
|
||||||
)
|
)
|
||||||
if registered:
|
if registered:
|
||||||
results[agent_name] = registered
|
results[agent_name] = registered
|
||||||
@@ -816,7 +906,7 @@ class CommandRegistrar:
|
|||||||
cmd_file = (
|
cmd_file = (
|
||||||
target_dir / f"{output_name}{agent_config['extension']}"
|
target_dir / f"{output_name}{agent_config['extension']}"
|
||||||
)
|
)
|
||||||
if cmd_file.exists():
|
if cmd_file.exists() or cmd_file.is_symlink():
|
||||||
cmd_file.unlink()
|
cmd_file.unlink()
|
||||||
# For SKILL.md agents each command lives in its own
|
# For SKILL.md agents each command lives in its own
|
||||||
# subdirectory (e.g. .agents/skills/speckit-ext-cmd/
|
# subdirectory (e.g. .agents/skills/speckit-ext-cmd/
|
||||||
|
|||||||
@@ -823,6 +823,7 @@ class ExtensionManager:
|
|||||||
self,
|
self,
|
||||||
manifest: ExtensionManifest,
|
manifest: ExtensionManifest,
|
||||||
extension_dir: Path,
|
extension_dir: Path,
|
||||||
|
link_outputs: bool = False,
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""Generate SKILL.md files for extension commands as agent skills.
|
"""Generate SKILL.md files for extension commands as agent skills.
|
||||||
|
|
||||||
@@ -834,6 +835,8 @@ class ExtensionManager:
|
|||||||
Args:
|
Args:
|
||||||
manifest: Extension manifest.
|
manifest: Extension manifest.
|
||||||
extension_dir: Installed extension directory.
|
extension_dir: Installed extension directory.
|
||||||
|
link_outputs: If True, create dev-mode symlinks for rendered
|
||||||
|
skill files when supported by the OS.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of skill names that were created (for registry storage).
|
List of skill names that were created (for registry storage).
|
||||||
@@ -886,9 +889,18 @@ class ExtensionManager:
|
|||||||
# Check if skill already exists before creating the directory
|
# Check if skill already exists before creating the directory
|
||||||
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 skill_file.exists():
|
cache_root = extension_dir / ".specify-dev" / "extension-skills"
|
||||||
# Do not overwrite user-customized skills
|
cache_file = cache_root / skill_name / "SKILL.md"
|
||||||
continue
|
CommandRegistrar._ensure_inside(cache_file, cache_root)
|
||||||
|
if skill_file.exists() or skill_file.is_symlink():
|
||||||
|
# Do not overwrite user-customized skills, but allow dev-mode
|
||||||
|
# symlinks that point back to this extension's generated cache
|
||||||
|
# to be refreshed on a subsequent dev install.
|
||||||
|
if not (
|
||||||
|
link_outputs
|
||||||
|
and self._is_expected_dev_symlink(skill_file, cache_file)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
# Create skill directory; track whether we created it so we can clean
|
# Create skill directory; track whether we created it so we can clean
|
||||||
# up safely if reading the source file subsequently fails.
|
# up safely if reading the source file subsequently fails.
|
||||||
@@ -940,11 +952,35 @@ class ExtensionManager:
|
|||||||
skill_content
|
skill_content
|
||||||
)
|
)
|
||||||
|
|
||||||
skill_file.write_text(skill_content, encoding="utf-8")
|
if link_outputs:
|
||||||
|
try:
|
||||||
|
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
cache_file.write_text(skill_content, encoding="utf-8")
|
||||||
|
if skill_file.exists() or skill_file.is_symlink():
|
||||||
|
skill_file.unlink()
|
||||||
|
target = os.path.relpath(cache_file, skill_file.parent)
|
||||||
|
os.symlink(target, skill_file)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
if skill_file.is_symlink():
|
||||||
|
skill_file.unlink()
|
||||||
|
skill_file.write_text(skill_content, encoding="utf-8")
|
||||||
|
else:
|
||||||
|
skill_file.write_text(skill_content, encoding="utf-8")
|
||||||
written.append(skill_name)
|
written.append(skill_name)
|
||||||
|
|
||||||
return written
|
return written
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_expected_dev_symlink(skill_file: Path, cache_file: Path) -> bool:
|
||||||
|
"""Return True when an existing skill file links to its dev cache."""
|
||||||
|
if not skill_file.is_symlink():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
return skill_file.resolve(strict=False) == cache_file.resolve(strict=False)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
def _unregister_extension_skills(
|
def _unregister_extension_skills(
|
||||||
self,
|
self,
|
||||||
skill_names: List[str],
|
skill_names: List[str],
|
||||||
@@ -1115,6 +1151,7 @@ class ExtensionManager:
|
|||||||
speckit_version: str,
|
speckit_version: str,
|
||||||
register_commands: bool = True,
|
register_commands: bool = True,
|
||||||
priority: int = 10,
|
priority: int = 10,
|
||||||
|
link_commands: bool = False,
|
||||||
) -> ExtensionManifest:
|
) -> ExtensionManifest:
|
||||||
"""Install extension from a local directory.
|
"""Install extension from a local directory.
|
||||||
|
|
||||||
@@ -1123,6 +1160,8 @@ class ExtensionManager:
|
|||||||
speckit_version: Current spec-kit version
|
speckit_version: Current spec-kit version
|
||||||
register_commands: If True, register commands with AI agents
|
register_commands: If True, register commands with AI agents
|
||||||
priority: Resolution priority (lower = higher precedence, default 10)
|
priority: Resolution priority (lower = higher precedence, default 10)
|
||||||
|
link_commands: If True, register rendered agent artifacts as
|
||||||
|
symlinks to a dev cache when supported by the OS.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Installed extension manifest
|
Installed extension manifest
|
||||||
@@ -1166,12 +1205,14 @@ class ExtensionManager:
|
|||||||
registrar = CommandRegistrar()
|
registrar = CommandRegistrar()
|
||||||
# Register for all detected agents
|
# Register for all detected agents
|
||||||
registered_commands = registrar.register_commands_for_all_agents(
|
registered_commands = registrar.register_commands_for_all_agents(
|
||||||
manifest, dest_dir, self.project_root
|
manifest, dest_dir, self.project_root, link_outputs=link_commands
|
||||||
)
|
)
|
||||||
|
|
||||||
# Auto-register extension commands as agent skills when --ai-skills
|
# Auto-register extension commands as agent skills when --ai-skills
|
||||||
# was used during project initialisation (feature parity).
|
# was used during project initialisation (feature parity).
|
||||||
registered_skills = self._register_extension_skills(manifest, dest_dir)
|
registered_skills = self._register_extension_skills(
|
||||||
|
manifest, dest_dir, link_outputs=link_commands
|
||||||
|
)
|
||||||
|
|
||||||
# Register hooks and update installed list in extensions.yml
|
# Register hooks and update installed list in extensions.yml
|
||||||
hook_executor = HookExecutor(self.project_root)
|
hook_executor = HookExecutor(self.project_root)
|
||||||
@@ -1607,7 +1648,8 @@ class CommandRegistrar:
|
|||||||
agent_name: str,
|
agent_name: str,
|
||||||
manifest: ExtensionManifest,
|
manifest: ExtensionManifest,
|
||||||
extension_dir: Path,
|
extension_dir: Path,
|
||||||
project_root: Path
|
project_root: Path,
|
||||||
|
link_outputs: bool = False,
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""Register extension commands for a specific agent."""
|
"""Register extension commands for a specific agent."""
|
||||||
if agent_name not in self.AGENT_CONFIGS:
|
if agent_name not in self.AGENT_CONFIGS:
|
||||||
@@ -1615,20 +1657,23 @@ class CommandRegistrar:
|
|||||||
context_note = f"\n<!-- Extension: {manifest.id} -->\n<!-- Config: .specify/extensions/{manifest.id}/ -->\n"
|
context_note = f"\n<!-- Extension: {manifest.id} -->\n<!-- Config: .specify/extensions/{manifest.id}/ -->\n"
|
||||||
return self._registrar.register_commands(
|
return self._registrar.register_commands(
|
||||||
agent_name, manifest.commands, manifest.id, extension_dir, project_root,
|
agent_name, manifest.commands, manifest.id, extension_dir, project_root,
|
||||||
context_note=context_note
|
context_note=context_note,
|
||||||
|
link_outputs=link_outputs,
|
||||||
)
|
)
|
||||||
|
|
||||||
def register_commands_for_all_agents(
|
def register_commands_for_all_agents(
|
||||||
self,
|
self,
|
||||||
manifest: ExtensionManifest,
|
manifest: ExtensionManifest,
|
||||||
extension_dir: Path,
|
extension_dir: Path,
|
||||||
project_root: Path
|
project_root: Path,
|
||||||
|
link_outputs: bool = False,
|
||||||
) -> Dict[str, List[str]]:
|
) -> Dict[str, List[str]]:
|
||||||
"""Register extension commands for all detected agents."""
|
"""Register extension commands for all detected agents."""
|
||||||
context_note = f"\n<!-- Extension: {manifest.id} -->\n<!-- Config: .specify/extensions/{manifest.id}/ -->\n"
|
context_note = f"\n<!-- Extension: {manifest.id} -->\n<!-- Config: .specify/extensions/{manifest.id}/ -->\n"
|
||||||
return self._registrar.register_commands_for_all_agents(
|
return self._registrar.register_commands_for_all_agents(
|
||||||
manifest.commands, manifest.id, extension_dir, project_root,
|
manifest.commands, manifest.id, extension_dir, project_root,
|
||||||
context_note=context_note
|
context_note=context_note,
|
||||||
|
link_outputs=link_outputs,
|
||||||
)
|
)
|
||||||
|
|
||||||
def unregister_commands(
|
def unregister_commands(
|
||||||
@@ -1643,10 +1688,13 @@ class CommandRegistrar:
|
|||||||
self,
|
self,
|
||||||
manifest: ExtensionManifest,
|
manifest: ExtensionManifest,
|
||||||
extension_dir: Path,
|
extension_dir: Path,
|
||||||
project_root: Path
|
project_root: Path,
|
||||||
|
link_outputs: bool = False,
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""Register extension commands for Claude Code agent."""
|
"""Register extension commands for Claude Code agent."""
|
||||||
return self.register_commands_for_agent("claude", manifest, extension_dir, project_root)
|
return self.register_commands_for_agent(
|
||||||
|
"claude", manifest, extension_dir, project_root, link_outputs=link_outputs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ExtensionCatalog(CatalogStackBase):
|
class ExtensionCatalog(CatalogStackBase):
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ def _register_builtins() -> None:
|
|||||||
from .gemini import GeminiIntegration
|
from .gemini import GeminiIntegration
|
||||||
from .generic import GenericIntegration
|
from .generic import GenericIntegration
|
||||||
from .goose import GooseIntegration
|
from .goose import GooseIntegration
|
||||||
|
from .hermes import HermesIntegration
|
||||||
from .iflow import IflowIntegration
|
from .iflow import IflowIntegration
|
||||||
from .junie import JunieIntegration
|
from .junie import JunieIntegration
|
||||||
from .kilocode import KilocodeIntegration
|
from .kilocode import KilocodeIntegration
|
||||||
@@ -93,6 +94,7 @@ def _register_builtins() -> None:
|
|||||||
_register(GeminiIntegration())
|
_register(GeminiIntegration())
|
||||||
_register(GenericIntegration())
|
_register(GenericIntegration())
|
||||||
_register(GooseIntegration())
|
_register(GooseIntegration())
|
||||||
|
_register(HermesIntegration())
|
||||||
_register(IflowIntegration())
|
_register(IflowIntegration())
|
||||||
_register(JunieIntegration())
|
_register(JunieIntegration())
|
||||||
_register(KilocodeIntegration())
|
_register(KilocodeIntegration())
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Antigravity uses ``.agents/skills/speckit-<name>/SKILL.md`` layout (enforced sin
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
@@ -13,6 +14,15 @@ from ..base import SkillsIntegration
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..manifest import IntegrationManifest
|
from ..manifest import IntegrationManifest
|
||||||
|
|
||||||
|
# Note injected into hook sections so agy maps dot-notation command
|
||||||
|
# names (from extensions.yml) to the hyphenated skill names it uses.
|
||||||
|
# Without this, agy emits ``/speckit.git.commit`` (which does not
|
||||||
|
# resolve) instead of ``/speckit-git-commit``.
|
||||||
|
_HOOK_COMMAND_NOTE = (
|
||||||
|
"- When constructing slash commands from hook command names, "
|
||||||
|
"replace dots (`.`) with hyphens (`-`). "
|
||||||
|
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AgyIntegration(SkillsIntegration):
|
class AgyIntegration(SkillsIntegration):
|
||||||
@@ -23,8 +33,8 @@ class AgyIntegration(SkillsIntegration):
|
|||||||
"name": "Antigravity",
|
"name": "Antigravity",
|
||||||
"folder": ".agents/",
|
"folder": ".agents/",
|
||||||
"commands_subdir": "skills",
|
"commands_subdir": "skills",
|
||||||
"install_url": None,
|
"install_url": "https://antigravity.google/",
|
||||||
"requires_cli": False,
|
"requires_cli": True,
|
||||||
}
|
}
|
||||||
registrar_config = {
|
registrar_config = {
|
||||||
"dir": ".agents/skills",
|
"dir": ".agents/skills",
|
||||||
@@ -34,6 +44,54 @@ class AgyIntegration(SkillsIntegration):
|
|||||||
}
|
}
|
||||||
context_file = "AGENTS.md"
|
context_file = "AGENTS.md"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _inject_hook_command_note(content: str) -> str:
|
||||||
|
"""Insert a dot-to-hyphen note before each hook output instruction.
|
||||||
|
|
||||||
|
Targets the line ``- For each executable hook, output the following``
|
||||||
|
and inserts the note on the line before it, matching its indentation.
|
||||||
|
Skips if the note is already present.
|
||||||
|
"""
|
||||||
|
if "replace dots" in content:
|
||||||
|
return content
|
||||||
|
|
||||||
|
def repl(m: re.Match[str]) -> str:
|
||||||
|
indent = m.group(1)
|
||||||
|
instruction = m.group(2)
|
||||||
|
# ``eol`` is empty when the regex matched via ``$`` because the
|
||||||
|
# instruction was the final line of a file with no trailing
|
||||||
|
# newline. Default to ``\n`` so the note never collapses onto
|
||||||
|
# the same line as the instruction.
|
||||||
|
eol = m.group(3) or "\n"
|
||||||
|
return (
|
||||||
|
indent
|
||||||
|
+ _HOOK_COMMAND_NOTE.rstrip("\n")
|
||||||
|
+ eol
|
||||||
|
+ indent
|
||||||
|
+ instruction
|
||||||
|
+ eol
|
||||||
|
)
|
||||||
|
|
||||||
|
return re.sub(
|
||||||
|
r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
|
||||||
|
repl,
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
|
||||||
|
def post_process_skill_content(self, content: str) -> str:
|
||||||
|
"""Inject the dot-to-hyphen hook command note."""
|
||||||
|
return self._inject_hook_command_note(content)
|
||||||
|
|
||||||
|
def build_exec_args(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
*,
|
||||||
|
model: str | None = None,
|
||||||
|
output_json: bool = True,
|
||||||
|
) -> list[str] | None:
|
||||||
|
# agy does not support --model or JSON output; both params are ignored
|
||||||
|
return ["agy", "--print", prompt]
|
||||||
|
|
||||||
def setup(
|
def setup(
|
||||||
self,
|
self,
|
||||||
project_root: Path,
|
project_root: Path,
|
||||||
@@ -49,4 +107,21 @@ class AgyIntegration(SkillsIntegration):
|
|||||||
fg="yellow",
|
fg="yellow",
|
||||||
err=True,
|
err=True,
|
||||||
)
|
)
|
||||||
return super().setup(project_root, manifest, parsed_options=parsed_options, **opts)
|
created = super().setup(project_root, manifest, parsed_options=parsed_options, **opts)
|
||||||
|
|
||||||
|
skills_dir = self.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_bytes().decode("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
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ import yaml
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .manifest import IntegrationManifest
|
from .manifest import IntegrationManifest
|
||||||
|
|
||||||
|
_HOOK_COMMAND_NOTE = (
|
||||||
|
"- When constructing slash commands from hook command names, "
|
||||||
|
"replace dots (`.`) with hyphens (`-`). "
|
||||||
|
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# IntegrationOption
|
# IntegrationOption
|
||||||
@@ -1391,15 +1397,53 @@ class SkillsIntegration(IntegrationBase):
|
|||||||
invocation = f"{invocation} {args}"
|
invocation = f"{invocation} {args}"
|
||||||
return invocation
|
return invocation
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _inject_hook_command_note(content: str) -> str:
|
||||||
|
"""Insert a dot-to-hyphen note before each hook output instruction.
|
||||||
|
|
||||||
|
Targets the line ``- For each executable hook, output the following``
|
||||||
|
and inserts the note on the line before it, matching its indentation.
|
||||||
|
Skips individual instructions that already have the note immediately
|
||||||
|
above them.
|
||||||
|
"""
|
||||||
|
note = _HOOK_COMMAND_NOTE.rstrip("\n")
|
||||||
|
|
||||||
|
def repl(m: re.Match[str]) -> str:
|
||||||
|
indent = m.group(1)
|
||||||
|
instruction = m.group(2)
|
||||||
|
previous_lines = content[:m.start()].splitlines()
|
||||||
|
if previous_lines and previous_lines[-1] == indent + note:
|
||||||
|
return m.group(0)
|
||||||
|
# ``eol`` is empty when the regex matched via ``$`` because the
|
||||||
|
# instruction was the final line of a file with no trailing
|
||||||
|
# newline. Default to ``\n`` so the note never collapses onto
|
||||||
|
# the same line as the instruction.
|
||||||
|
eol = m.group(3) or "\n"
|
||||||
|
return (
|
||||||
|
indent
|
||||||
|
+ note
|
||||||
|
+ eol
|
||||||
|
+ indent
|
||||||
|
+ instruction
|
||||||
|
+ eol
|
||||||
|
)
|
||||||
|
|
||||||
|
return re.sub(
|
||||||
|
r"(?m)^([ \t]*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
|
||||||
|
repl,
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
|
||||||
def post_process_skill_content(self, content: str) -> str:
|
def post_process_skill_content(self, content: str) -> str:
|
||||||
"""Post-process a SKILL.md file's content after generation.
|
"""Post-process a SKILL.md file's content after generation.
|
||||||
|
|
||||||
Called by external skill generators (presets, extensions) to let
|
Called by external skill generators (presets, extensions) to let
|
||||||
the integration inject agent-specific frontmatter or body
|
the integration inject agent-specific frontmatter or body
|
||||||
transformations. The default implementation returns *content*
|
transformations. The base implementation injects shared skills
|
||||||
unchanged. Subclasses may override — see ``ClaudeIntegration``.
|
guidance for converting dotted hook command names to hyphenated
|
||||||
|
slash commands. Subclasses may override — see ``ClaudeIntegration``.
|
||||||
"""
|
"""
|
||||||
return content
|
return self._inject_hook_command_note(content)
|
||||||
|
|
||||||
def setup(
|
def setup(
|
||||||
self,
|
self,
|
||||||
@@ -1502,6 +1546,8 @@ class SkillsIntegration(IntegrationBase):
|
|||||||
f"{processed_body}"
|
f"{processed_body}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
skill_content = self.post_process_skill_content(skill_content)
|
||||||
|
|
||||||
# Write speckit-<name>/SKILL.md
|
# Write speckit-<name>/SKILL.md
|
||||||
skill_dir = skills_dir / skill_name
|
skill_dir = skills_dir / skill_name
|
||||||
skill_file = skill_dir / "SKILL.md"
|
skill_file = skill_dir / "SKILL.md"
|
||||||
|
|||||||
@@ -5,21 +5,11 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from ..base import SkillsIntegration
|
from ..base import SkillsIntegration
|
||||||
from ..manifest import IntegrationManifest
|
from ..manifest import IntegrationManifest
|
||||||
|
|
||||||
# Note injected into hook sections so Claude maps dot-notation command
|
|
||||||
# names (from extensions.yml) to the hyphenated skill names it uses.
|
|
||||||
_HOOK_COMMAND_NOTE = (
|
|
||||||
"- When constructing slash commands from hook command names, "
|
|
||||||
"replace dots (`.`) with hyphens (`-`). "
|
|
||||||
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mapping of command template stem → argument-hint text shown inline
|
# Mapping of command template stem → argument-hint text shown inline
|
||||||
# when a user invokes the slash command in Claude Code.
|
# when a user invokes the slash command in Claude Code.
|
||||||
ARGUMENT_HINTS: dict[str, str] = {
|
ARGUMENT_HINTS: dict[str, str] = {
|
||||||
@@ -159,41 +149,11 @@ class ClaudeIntegration(SkillsIntegration):
|
|||||||
out.append(line)
|
out.append(line)
|
||||||
return "".join(out)
|
return "".join(out)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _inject_hook_command_note(content: str) -> str:
|
|
||||||
"""Insert a dot-to-hyphen note before each hook output instruction.
|
|
||||||
|
|
||||||
Targets the line ``- For each executable hook, output the following``
|
|
||||||
and inserts the note on the line before it, matching its indentation.
|
|
||||||
Skips if the note is already present.
|
|
||||||
"""
|
|
||||||
if "replace dots" in content:
|
|
||||||
return content
|
|
||||||
|
|
||||||
def repl(m: re.Match[str]) -> str:
|
|
||||||
indent = m.group(1)
|
|
||||||
instruction = m.group(2)
|
|
||||||
eol = m.group(3)
|
|
||||||
return (
|
|
||||||
indent
|
|
||||||
+ _HOOK_COMMAND_NOTE.rstrip("\n")
|
|
||||||
+ eol
|
|
||||||
+ indent
|
|
||||||
+ instruction
|
|
||||||
+ eol
|
|
||||||
)
|
|
||||||
|
|
||||||
return re.sub(
|
|
||||||
r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
|
|
||||||
repl,
|
|
||||||
content,
|
|
||||||
)
|
|
||||||
|
|
||||||
def post_process_skill_content(self, content: str) -> str:
|
def post_process_skill_content(self, content: str) -> str:
|
||||||
"""Inject Claude-specific frontmatter flags and hook notes."""
|
"""Inject Claude-specific frontmatter flags and hook notes."""
|
||||||
updated = self._inject_frontmatter_flag(content, "user-invocable")
|
updated = super().post_process_skill_content(content)
|
||||||
|
updated = self._inject_frontmatter_flag(updated, "user-invocable")
|
||||||
updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false")
|
updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false")
|
||||||
updated = self._inject_hook_command_note(updated)
|
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
def setup(
|
def setup(
|
||||||
@@ -203,10 +163,9 @@ class ClaudeIntegration(SkillsIntegration):
|
|||||||
parsed_options: dict[str, Any] | None = None,
|
parsed_options: dict[str, Any] | None = None,
|
||||||
**opts: Any,
|
**opts: Any,
|
||||||
) -> list[Path]:
|
) -> list[Path]:
|
||||||
"""Install Claude skills, then inject Claude-specific flags and argument-hints."""
|
"""Install Claude skills, then inject argument-hints."""
|
||||||
created = super().setup(project_root, manifest, parsed_options, **opts)
|
created = super().setup(project_root, manifest, parsed_options, **opts)
|
||||||
|
|
||||||
# Post-process generated skill files
|
|
||||||
skills_dir = self.skills_dest(project_root).resolve()
|
skills_dir = self.skills_dest(project_root).resolve()
|
||||||
|
|
||||||
for path in created:
|
for path in created:
|
||||||
@@ -221,7 +180,7 @@ class ClaudeIntegration(SkillsIntegration):
|
|||||||
content_bytes = path.read_bytes()
|
content_bytes = path.read_bytes()
|
||||||
content = content_bytes.decode("utf-8")
|
content = content_bytes.decode("utf-8")
|
||||||
|
|
||||||
updated = self.post_process_skill_content(content)
|
updated = content
|
||||||
|
|
||||||
# Inject argument-hint if available for this skill
|
# Inject argument-hint if available for this skill
|
||||||
skill_dir_name = path.parent.name # e.g. "speckit-plan"
|
skill_dir_name = path.parent.name # e.g. "speckit-plan"
|
||||||
|
|||||||
@@ -6,22 +6,7 @@ Commands are deprecated; ``--skills`` defaults to ``True``.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from ..base import IntegrationOption, SkillsIntegration
|
from ..base import IntegrationOption, SkillsIntegration
|
||||||
from ..manifest import IntegrationManifest
|
|
||||||
|
|
||||||
# Note injected into hook sections so Codex maps dot-notation command
|
|
||||||
# names (from extensions.yml) to the hyphenated skill names it uses.
|
|
||||||
# Without this, Codex emits ``/speckit.git.commit`` (which does not
|
|
||||||
# resolve) instead of ``/speckit-git-commit``.
|
|
||||||
_HOOK_COMMAND_NOTE = (
|
|
||||||
"- When constructing slash commands from hook command names, "
|
|
||||||
"replace dots (`.`) with hyphens (`-`). "
|
|
||||||
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CodexIntegration(SkillsIntegration):
|
class CodexIntegration(SkillsIntegration):
|
||||||
@@ -69,68 +54,3 @@ class CodexIntegration(SkillsIntegration):
|
|||||||
help="Install as agent skills (default for Codex)",
|
help="Install as agent skills (default for Codex)",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _inject_hook_command_note(content: str) -> str:
|
|
||||||
"""Insert a dot-to-hyphen note before each hook output instruction.
|
|
||||||
|
|
||||||
Targets the line ``- For each executable hook, output the following``
|
|
||||||
and inserts the note on the line before it, matching its indentation.
|
|
||||||
Skips if the note is already present.
|
|
||||||
"""
|
|
||||||
if "replace dots" in content:
|
|
||||||
return content
|
|
||||||
|
|
||||||
def repl(m: re.Match[str]) -> str:
|
|
||||||
indent = m.group(1)
|
|
||||||
instruction = m.group(2)
|
|
||||||
# ``eol`` is empty when the regex matched via ``$`` because the
|
|
||||||
# instruction was the final line of a file with no trailing
|
|
||||||
# newline. Default to ``\n`` so the note never collapses onto
|
|
||||||
# the same line as the instruction.
|
|
||||||
eol = m.group(3) or "\n"
|
|
||||||
return (
|
|
||||||
indent
|
|
||||||
+ _HOOK_COMMAND_NOTE.rstrip("\n")
|
|
||||||
+ eol
|
|
||||||
+ indent
|
|
||||||
+ instruction
|
|
||||||
+ eol
|
|
||||||
)
|
|
||||||
|
|
||||||
return re.sub(
|
|
||||||
r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
|
|
||||||
repl,
|
|
||||||
content,
|
|
||||||
)
|
|
||||||
|
|
||||||
def post_process_skill_content(self, content: str) -> str:
|
|
||||||
"""Inject the dot-to-hyphen hook command note."""
|
|
||||||
return self._inject_hook_command_note(content)
|
|
||||||
|
|
||||||
def setup(
|
|
||||||
self,
|
|
||||||
project_root: Path,
|
|
||||||
manifest: IntegrationManifest,
|
|
||||||
parsed_options: dict[str, Any] | None = None,
|
|
||||||
**opts: Any,
|
|
||||||
) -> list[Path]:
|
|
||||||
"""Install Codex skills, then inject the hook command note."""
|
|
||||||
created = super().setup(project_root, manifest, parsed_options, **opts)
|
|
||||||
|
|
||||||
skills_dir = self.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_bytes().decode("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
|
|
||||||
|
|||||||
@@ -265,12 +265,13 @@ class CopilotIntegration(IntegrationBase):
|
|||||||
return f"speckit.{template_name}.agent.md"
|
return f"speckit.{template_name}.agent.md"
|
||||||
|
|
||||||
def post_process_skill_content(self, content: str) -> str:
|
def post_process_skill_content(self, content: str) -> str:
|
||||||
"""Inject Copilot-specific ``mode:`` field into SKILL.md frontmatter.
|
"""Inject shared hook guidance and Copilot ``mode:`` frontmatter.
|
||||||
|
|
||||||
Inserts ``mode: speckit.<stem>`` before the closing ``---`` so
|
Inserts ``mode: speckit.<stem>`` before the closing ``---`` so
|
||||||
Copilot can associate the skill with its agent mode.
|
Copilot can associate the skill with its agent mode.
|
||||||
"""
|
"""
|
||||||
lines = content.splitlines(keepends=True)
|
updated = _CopilotSkillsHelper().post_process_skill_content(content)
|
||||||
|
lines = updated.splitlines(keepends=True)
|
||||||
|
|
||||||
# Extract skill name from frontmatter to derive the mode value
|
# Extract skill name from frontmatter to derive the mode value
|
||||||
dash_count = 0
|
dash_count = 0
|
||||||
@@ -284,7 +285,7 @@ class CopilotIntegration(IntegrationBase):
|
|||||||
continue
|
continue
|
||||||
if dash_count == 1:
|
if dash_count == 1:
|
||||||
if stripped.startswith("mode:"):
|
if stripped.startswith("mode:"):
|
||||||
return content # already present
|
return updated # already present
|
||||||
if stripped.startswith("name:"):
|
if stripped.startswith("name:"):
|
||||||
# Parse: name: "speckit-plan" → speckit.plan
|
# Parse: name: "speckit-plan" → speckit.plan
|
||||||
val = stripped.split(":", 1)[1].strip().strip('"').strip("'")
|
val = stripped.split(":", 1)[1].strip().strip('"').strip("'")
|
||||||
@@ -295,7 +296,7 @@ class CopilotIntegration(IntegrationBase):
|
|||||||
skill_name = val
|
skill_name = val
|
||||||
|
|
||||||
if not skill_name:
|
if not skill_name:
|
||||||
return content
|
return updated
|
||||||
|
|
||||||
# Inject mode: before the closing --- of frontmatter
|
# Inject mode: before the closing --- of frontmatter
|
||||||
out: list[str] = []
|
out: list[str] = []
|
||||||
|
|||||||
280
src/specify_cli/integrations/hermes/__init__.py
Normal file
280
src/specify_cli/integrations/hermes/__init__.py
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
"""Hermes Agent integration — skills-based agent.
|
||||||
|
|
||||||
|
Hermes Agent (https://github.com/NousResearch/hermes-agent) is an open-source
|
||||||
|
AI agent framework by Nous Research. It stores skills in
|
||||||
|
``~/.hermes/skills/`` (user-global) rather than a project-local directory.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
specify init my-project --integration hermes
|
||||||
|
specify init --here --ai hermes
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from shutil import rmtree
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from ..base import IntegrationOption, SkillsIntegration
|
||||||
|
from ..manifest import IntegrationManifest
|
||||||
|
|
||||||
|
|
||||||
|
class HermesIntegration(SkillsIntegration):
|
||||||
|
"""Integration for Hermes Agent skills.
|
||||||
|
|
||||||
|
Hermes loads skills from ``~/.hermes/skills/`` (user home directory)
|
||||||
|
rather than a project-local path. Skills are installed directly to
|
||||||
|
the global directory — no project-local copies are created since
|
||||||
|
Hermes discovers them globally. A project-local marker directory
|
||||||
|
(``.hermes/skills/`` empty) is created so extension commands (e.g.
|
||||||
|
git) can detect Hermes as an active integration. Uninstall removes
|
||||||
|
both the marker and all global ``speckit-*`` skills, matching the
|
||||||
|
standard integration teardown behaviour.
|
||||||
|
"""
|
||||||
|
|
||||||
|
key = "hermes"
|
||||||
|
config = {
|
||||||
|
"name": "Hermes Agent",
|
||||||
|
"folder": ".hermes/",
|
||||||
|
"commands_subdir": "skills",
|
||||||
|
"install_url": "https://github.com/NousResearch/hermes-agent",
|
||||||
|
"requires_cli": True,
|
||||||
|
}
|
||||||
|
registrar_config = {
|
||||||
|
"dir": "~/.hermes/skills",
|
||||||
|
"detect_dir": ".hermes/skills",
|
||||||
|
"format": "markdown",
|
||||||
|
"args": "$ARGUMENTS",
|
||||||
|
"extension": "/SKILL.md",
|
||||||
|
}
|
||||||
|
context_file = "AGENTS.md"
|
||||||
|
|
||||||
|
# -- Helpers -----------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _hermes_home_skills_dir() -> Path:
|
||||||
|
"""Return ``~/.hermes/skills/`` — the global skills directory."""
|
||||||
|
return Path.home() / ".hermes" / "skills"
|
||||||
|
|
||||||
|
# -- Options -----------------------------------------------------------
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def options(cls) -> list[IntegrationOption]:
|
||||||
|
return [
|
||||||
|
IntegrationOption(
|
||||||
|
"--skills",
|
||||||
|
is_flag=True,
|
||||||
|
default=True,
|
||||||
|
help="Install as agent skills (default for Hermes Agent)",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# -- Setup -------------------------------------------------------------
|
||||||
|
|
||||||
|
def setup(
|
||||||
|
self,
|
||||||
|
project_root: Path,
|
||||||
|
manifest: IntegrationManifest,
|
||||||
|
parsed_options: dict[str, Any] | None = None,
|
||||||
|
**opts: Any,
|
||||||
|
) -> list[Path]:
|
||||||
|
"""Install command templates as global Hermes skills.
|
||||||
|
|
||||||
|
Writes each skill directly to
|
||||||
|
``~/.hermes/skills/speckit-<name>/SKILL.md`` where Hermes
|
||||||
|
discovers them at runtime. No project-local SKILL.md copies are
|
||||||
|
created — the global directory is the single source of truth.
|
||||||
|
A project-local marker (``.hermes/skills/`` empty) is created
|
||||||
|
so extension commands (e.g. git) can detect Hermes as an active
|
||||||
|
integration.
|
||||||
|
"""
|
||||||
|
templates = self.list_command_templates()
|
||||||
|
if not templates:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Safety check: verify manifest project_root matches (standard pattern)
|
||||||
|
project_root_resolved = project_root.resolve()
|
||||||
|
if manifest.project_root != project_root_resolved:
|
||||||
|
raise ValueError(
|
||||||
|
f"manifest.project_root ({manifest.project_root}) does not match "
|
||||||
|
f"project_root ({project_root_resolved})"
|
||||||
|
)
|
||||||
|
|
||||||
|
script_type = opts.get("script_type", "sh")
|
||||||
|
arg_placeholder = (
|
||||||
|
self.registrar_config.get("args", "$ARGUMENTS")
|
||||||
|
if self.registrar_config
|
||||||
|
else "$ARGUMENTS"
|
||||||
|
)
|
||||||
|
|
||||||
|
global_skills_dir = self._hermes_home_skills_dir()
|
||||||
|
global_skills_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
created: list[Path] = []
|
||||||
|
|
||||||
|
for src_file in templates:
|
||||||
|
raw = src_file.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# Derive the skill name from the template stem
|
||||||
|
command_name = src_file.stem # e.g. "plan"
|
||||||
|
skill_name = f"speckit-{command_name.replace('.', '-')}"
|
||||||
|
|
||||||
|
# Parse frontmatter for description
|
||||||
|
frontmatter: dict[str, Any] = {}
|
||||||
|
if raw.startswith("---"):
|
||||||
|
parts = raw.split("---", 2)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
try:
|
||||||
|
fm = yaml.safe_load(parts[1])
|
||||||
|
if isinstance(fm, dict):
|
||||||
|
frontmatter = fm
|
||||||
|
except yaml.YAMLError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Process body through the standard template pipeline
|
||||||
|
processed_body = self.process_template(
|
||||||
|
raw,
|
||||||
|
self.key,
|
||||||
|
script_type,
|
||||||
|
arg_placeholder,
|
||||||
|
context_file=self.context_file or "",
|
||||||
|
invoke_separator=self.invoke_separator,
|
||||||
|
)
|
||||||
|
# Strip the processed frontmatter — we rebuild it for skills.
|
||||||
|
if processed_body.startswith("---"):
|
||||||
|
parts = processed_body.split("---", 2)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
processed_body = parts[2]
|
||||||
|
|
||||||
|
# Select description
|
||||||
|
description = frontmatter.get("description", "")
|
||||||
|
if not description:
|
||||||
|
description = f"Spec Kit: {command_name} workflow"
|
||||||
|
|
||||||
|
# Build SKILL.md with manually formatted frontmatter
|
||||||
|
def _quote(v: str) -> str:
|
||||||
|
escaped = v.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
return f'"{escaped}"'
|
||||||
|
|
||||||
|
skill_content = (
|
||||||
|
f"---\n"
|
||||||
|
f"name: {_quote(skill_name)}\n"
|
||||||
|
f"description: {_quote(description)}\n"
|
||||||
|
f"compatibility: "
|
||||||
|
f"{_quote('Requires spec-kit project structure with .specify/ directory')}\n"
|
||||||
|
f"metadata:\n"
|
||||||
|
f" author: {_quote('github-spec-kit')}\n"
|
||||||
|
f" source: {_quote('templates/commands/' + src_file.name)}\n"
|
||||||
|
f"---\n"
|
||||||
|
f"{processed_body}"
|
||||||
|
)
|
||||||
|
|
||||||
|
skill_content = self.post_process_skill_content(skill_content)
|
||||||
|
|
||||||
|
# Write directly to global ~/.hermes/skills/speckit-<name>/SKILL.md
|
||||||
|
skill_dir = global_skills_dir / skill_name
|
||||||
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
skill_file = skill_dir / "SKILL.md"
|
||||||
|
normalized = skill_content.replace("\r\n", "\n")
|
||||||
|
skill_file.write_bytes(normalized.encode("utf-8"))
|
||||||
|
created.append(skill_file)
|
||||||
|
|
||||||
|
# Upsert managed context section into the agent context file
|
||||||
|
self.upsert_context_section(project_root)
|
||||||
|
|
||||||
|
# Create project-local marker directory so extension commands
|
||||||
|
# (e.g. git) can detect Hermes as an active integration.
|
||||||
|
# Hermes itself ignores this directory — skills live globally.
|
||||||
|
(project_root / ".hermes" / "skills").mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
return created
|
||||||
|
|
||||||
|
# -- Uninstall ---------------------------------------------------------
|
||||||
|
|
||||||
|
def teardown(
|
||||||
|
self,
|
||||||
|
project_root: Path,
|
||||||
|
manifest: IntegrationManifest,
|
||||||
|
*,
|
||||||
|
force: bool = False,
|
||||||
|
) -> tuple[list[Path], list[Path]]:
|
||||||
|
"""Uninstall integration files including global Hermes skills.
|
||||||
|
|
||||||
|
Removes the managed context section from AGENTS.md, removes the
|
||||||
|
project-local marker directory (if empty), delegates to
|
||||||
|
``manifest.uninstall()`` for project-local tracked files, and
|
||||||
|
removes all ``speckit-*`` skills under ``~/.hermes/skills/``.
|
||||||
|
|
||||||
|
Global skills are always removed on teardown — this matches the
|
||||||
|
standard integration behaviour where all files created by the
|
||||||
|
integration are removed on ``specify integration uninstall``.
|
||||||
|
"""
|
||||||
|
# Remove managed context section from AGENTS.md
|
||||||
|
self.remove_context_section(project_root)
|
||||||
|
|
||||||
|
# Delegate to manifest for project-local tracked files (scripts,
|
||||||
|
# templates, context entries tracked in the manifest).
|
||||||
|
removed, skipped = manifest.uninstall(project_root, force=force)
|
||||||
|
|
||||||
|
# Remove project-local marker directory if empty
|
||||||
|
local_skills_dir = project_root / ".hermes" / "skills"
|
||||||
|
if local_skills_dir.is_dir() and not any(local_skills_dir.iterdir()):
|
||||||
|
local_skills_dir.rmdir()
|
||||||
|
hermes_dir = project_root / ".hermes"
|
||||||
|
if hermes_dir.is_dir() and not any(hermes_dir.iterdir()):
|
||||||
|
hermes_dir.rmdir()
|
||||||
|
|
||||||
|
# Remove all global Hermes skills for speckit — these are always
|
||||||
|
# removed on uninstall regardless of the force flag, matching the
|
||||||
|
# standard behaviour where all integration files are cleaned up.
|
||||||
|
global_skills_dir = self._hermes_home_skills_dir()
|
||||||
|
if global_skills_dir.is_dir():
|
||||||
|
for skill_dir in sorted(global_skills_dir.iterdir()):
|
||||||
|
if skill_dir.is_dir() and skill_dir.name.startswith("speckit-"):
|
||||||
|
try:
|
||||||
|
rmtree(skill_dir)
|
||||||
|
removed.append(skill_dir)
|
||||||
|
except OSError:
|
||||||
|
skipped.append(skill_dir)
|
||||||
|
|
||||||
|
return removed, skipped
|
||||||
|
|
||||||
|
# -- CLI dispatch ------------------------------------------------------
|
||||||
|
|
||||||
|
def build_exec_args(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
*,
|
||||||
|
model: str | None = None,
|
||||||
|
output_json: bool = True,
|
||||||
|
) -> list[str] | None:
|
||||||
|
"""Build Hermes CLI invocation for programmatic dispatch.
|
||||||
|
|
||||||
|
Uses ``hermes chat -Q -q`` for one-shot queries in quiet mode,
|
||||||
|
mapping slash-command invocations to the appropriate skill-based
|
||||||
|
dispatch.
|
||||||
|
"""
|
||||||
|
args = [self.key, "chat", "-Q"]
|
||||||
|
|
||||||
|
if model:
|
||||||
|
args.extend(["-m", model])
|
||||||
|
if output_json:
|
||||||
|
args.append("--json")
|
||||||
|
|
||||||
|
# If prompt starts with a slash command, pass it directly
|
||||||
|
# so Hermes can dispatch to the appropriate skill.
|
||||||
|
if prompt.startswith("/"):
|
||||||
|
command, _, remainder = prompt[1:].partition(" ")
|
||||||
|
if command:
|
||||||
|
args.extend(["-s", command])
|
||||||
|
if remainder:
|
||||||
|
args.extend(["-q", remainder])
|
||||||
|
else:
|
||||||
|
args.extend(["-q", prompt])
|
||||||
|
else:
|
||||||
|
args.extend(["-q", prompt])
|
||||||
|
|
||||||
|
return args
|
||||||
@@ -81,13 +81,13 @@ class VibeIntegration(SkillsIntegration):
|
|||||||
out.append(line)
|
out.append(line)
|
||||||
return "".join(out)
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
def post_process_skill_content(self, content: str) -> str:
|
def post_process_skill_content(self, content: str) -> str:
|
||||||
"""
|
"""
|
||||||
Inject Vibe-specific frontmatter flags:
|
Inject shared hook guidance and Vibe-specific frontmatter flags:
|
||||||
- user-invocable: allows the skill to be invoked by the user (not just other agents)
|
- user-invocable: allows the skill to be invoked by the user (not just other agents)
|
||||||
"""
|
"""
|
||||||
updated = self._inject_frontmatter_flag(content, "user-invocable")
|
updated = super().post_process_skill_content(content)
|
||||||
|
updated = self._inject_frontmatter_flag(updated, "user-invocable")
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
def setup(
|
def setup(
|
||||||
@@ -107,27 +107,4 @@ class VibeIntegration(SkillsIntegration):
|
|||||||
err=True,
|
err=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
created = super().setup(project_root, manifest, parsed_options=parsed_options, **opts)
|
return super().setup(project_root, manifest, parsed_options=parsed_options, **opts)
|
||||||
|
|
||||||
# Post-process generated skill files
|
|
||||||
skills_dir = self.skills_dest(project_root).resolve()
|
|
||||||
|
|
||||||
for path in created:
|
|
||||||
# Only touch SKILL.md files under the skills directory
|
|
||||||
try:
|
|
||||||
path.resolve().relative_to(skills_dir)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if path.name != "SKILL.md":
|
|
||||||
continue
|
|
||||||
|
|
||||||
content_bytes = path.read_bytes()
|
|
||||||
content = content_bytes.decode("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
|
|
||||||
|
|||||||
@@ -369,7 +369,16 @@ def install_shared_infra(
|
|||||||
|
|
||||||
if not _ensure_or_bucket_dir(dst_path.parent):
|
if not _ensure_or_bucket_dir(dst_path.parent):
|
||||||
continue
|
continue
|
||||||
planned_copies.append((dst_path, rel, src_path.read_bytes(), src_path.stat().st_mode & 0o777))
|
content = src_path.read_text(encoding="utf-8")
|
||||||
|
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
|
||||||
|
planned_copies.append(
|
||||||
|
(
|
||||||
|
dst_path,
|
||||||
|
rel,
|
||||||
|
content.encode("utf-8"),
|
||||||
|
src_path.stat().st_mode & 0o777,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root)
|
templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root)
|
||||||
if templates_src.is_dir():
|
if templates_src.is_dir():
|
||||||
|
|||||||
@@ -923,7 +923,23 @@ class TestGitExtensionAutoInstall:
|
|||||||
|
|
||||||
|
|
||||||
class TestSharedInfraCommandRefs:
|
class TestSharedInfraCommandRefs:
|
||||||
"""Verify _install_shared_infra resolves __SPECKIT_COMMAND_*__ in page templates."""
|
"""Verify _install_shared_infra resolves __SPECKIT_COMMAND_*__ in shared infra."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _combined_script_content(project, script_type):
|
||||||
|
script_dir = "bash" if script_type == "sh" else "powershell"
|
||||||
|
suffix = "sh" if script_type == "sh" else "ps1"
|
||||||
|
names = [
|
||||||
|
f"check-prerequisites.{suffix}",
|
||||||
|
f"common.{suffix}",
|
||||||
|
f"setup-tasks.{suffix}",
|
||||||
|
]
|
||||||
|
return "\n".join(
|
||||||
|
(project / ".specify" / "scripts" / script_dir / name).read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
for name in names
|
||||||
|
)
|
||||||
|
|
||||||
def test_dot_separator_in_page_templates(self, tmp_path):
|
def test_dot_separator_in_page_templates(self, tmp_path):
|
||||||
"""Markdown agents get /speckit.<name> in page templates."""
|
"""Markdown agents get /speckit.<name> in page templates."""
|
||||||
@@ -968,6 +984,46 @@ class TestSharedInfraCommandRefs:
|
|||||||
assert "__SPECKIT_COMMAND_" not in content
|
assert "__SPECKIT_COMMAND_" not in content
|
||||||
assert "/speckit-tasks" in content
|
assert "/speckit-tasks" in content
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("script_type", ["sh", "ps"])
|
||||||
|
def test_dot_separator_in_shared_scripts(self, tmp_path, script_type):
|
||||||
|
"""Markdown agents get /speckit.<name> in shared script hints."""
|
||||||
|
from specify_cli import _install_shared_infra
|
||||||
|
|
||||||
|
project = tmp_path / f"dot-script-{script_type}"
|
||||||
|
project.mkdir()
|
||||||
|
(project / ".specify").mkdir()
|
||||||
|
|
||||||
|
_install_shared_infra(project, script_type, invoke_separator=".")
|
||||||
|
|
||||||
|
content = self._combined_script_content(project, script_type)
|
||||||
|
assert "__SPECKIT_COMMAND_" not in content
|
||||||
|
assert "/speckit.specify" in content
|
||||||
|
assert "/speckit.plan" in content
|
||||||
|
assert "/speckit.tasks" in content
|
||||||
|
assert "/speckit-specify" not in content
|
||||||
|
assert "/speckit-plan" not in content
|
||||||
|
assert "/speckit-tasks" not in content
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("script_type", ["sh", "ps"])
|
||||||
|
def test_hyphen_separator_in_shared_scripts(self, tmp_path, script_type):
|
||||||
|
"""Skills agents get /speckit-<name> in shared script hints."""
|
||||||
|
from specify_cli import _install_shared_infra
|
||||||
|
|
||||||
|
project = tmp_path / f"hyphen-script-{script_type}"
|
||||||
|
project.mkdir()
|
||||||
|
(project / ".specify").mkdir()
|
||||||
|
|
||||||
|
_install_shared_infra(project, script_type, invoke_separator="-")
|
||||||
|
|
||||||
|
content = self._combined_script_content(project, script_type)
|
||||||
|
assert "__SPECKIT_COMMAND_" not in content
|
||||||
|
assert "/speckit-specify" in content
|
||||||
|
assert "/speckit-plan" in content
|
||||||
|
assert "/speckit-tasks" in content
|
||||||
|
assert "/speckit.specify" not in content
|
||||||
|
assert "/speckit.plan" not in content
|
||||||
|
assert "/speckit.tasks" not in content
|
||||||
|
|
||||||
def test_full_init_claude_resolves_page_templates(self, tmp_path):
|
def test_full_init_claude_resolves_page_templates(self, tmp_path):
|
||||||
"""Full CLI init with Claude (skills agent) produces hyphen refs in page templates."""
|
"""Full CLI init with Claude (skills agent) produces hyphen refs in page templates."""
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
@@ -995,6 +1051,10 @@ class TestSharedInfraCommandRefs:
|
|||||||
assert "/speckit-plan" in content, "Claude (skills) should use /speckit-plan"
|
assert "/speckit-plan" in content, "Claude (skills) should use /speckit-plan"
|
||||||
assert "__SPECKIT_COMMAND_" not in content
|
assert "__SPECKIT_COMMAND_" not in content
|
||||||
|
|
||||||
|
script_content = self._combined_script_content(project, "sh")
|
||||||
|
assert "/speckit-specify" in script_content
|
||||||
|
assert "/speckit.specify" not in script_content
|
||||||
|
|
||||||
def test_full_init_copilot_resolves_page_templates(self, tmp_path):
|
def test_full_init_copilot_resolves_page_templates(self, tmp_path):
|
||||||
"""Full CLI init with Copilot (markdown agent) produces dot refs in page templates."""
|
"""Full CLI init with Copilot (markdown agent) produces dot refs in page templates."""
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
@@ -1022,6 +1082,10 @@ class TestSharedInfraCommandRefs:
|
|||||||
assert "/speckit.plan" in content, "Copilot (markdown) should use /speckit.plan"
|
assert "/speckit.plan" in content, "Copilot (markdown) should use /speckit.plan"
|
||||||
assert "__SPECKIT_COMMAND_" not in content
|
assert "__SPECKIT_COMMAND_" not in content
|
||||||
|
|
||||||
|
script_content = self._combined_script_content(project, "sh")
|
||||||
|
assert "/speckit.specify" in script_content
|
||||||
|
assert "/speckit-specify" not in script_content
|
||||||
|
|
||||||
def test_full_init_copilot_skills_resolves_page_templates(self, tmp_path):
|
def test_full_init_copilot_skills_resolves_page_templates(self, tmp_path):
|
||||||
"""Full CLI init with Copilot --skills produces hyphen refs in page templates."""
|
"""Full CLI init with Copilot --skills produces hyphen refs in page templates."""
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
@@ -1051,6 +1115,10 @@ class TestSharedInfraCommandRefs:
|
|||||||
assert "/speckit.plan" not in content, "dot-notation leaked into Copilot skills page template"
|
assert "/speckit.plan" not in content, "dot-notation leaked into Copilot skills page template"
|
||||||
assert "__SPECKIT_COMMAND_" not in content
|
assert "__SPECKIT_COMMAND_" not in content
|
||||||
|
|
||||||
|
script_content = self._combined_script_content(project, "sh")
|
||||||
|
assert "/speckit-specify" in script_content
|
||||||
|
assert "/speckit.specify" not in script_content
|
||||||
|
|
||||||
|
|
||||||
class TestIntegrationCatalogDiscoveryCLI:
|
class TestIntegrationCatalogDiscoveryCLI:
|
||||||
"""End-to-end CLI tests for `integration search`, `info`, and `catalog …`.
|
"""End-to-end CLI tests for `integration search`, `info`, and `catalog …`.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Tests for AgyIntegration (Antigravity)."""
|
"""Tests for AgyIntegration (Antigravity)."""
|
||||||
|
|
||||||
|
from specify_cli.integrations import get_integration
|
||||||
|
|
||||||
from .test_integration_base_skills import SkillsIntegrationTests
|
from .test_integration_base_skills import SkillsIntegrationTests
|
||||||
|
|
||||||
|
|
||||||
@@ -12,10 +14,21 @@ class TestAgyIntegration(SkillsIntegrationTests):
|
|||||||
|
|
||||||
def test_options_include_skills_flag(self):
|
def test_options_include_skills_flag(self):
|
||||||
"""Override inherited test: AgyIntegration should not expose a --skills flag because .agents/ is its only layout."""
|
"""Override inherited test: AgyIntegration should not expose a --skills flag because .agents/ is its only layout."""
|
||||||
from specify_cli.integrations import get_integration
|
|
||||||
i = get_integration(self.KEY)
|
i = get_integration(self.KEY)
|
||||||
skills_opts = [o for o in i.options() if o.name == "--skills"]
|
skills_opts = [o for o in i.options() if o.name == "--skills"]
|
||||||
assert len(skills_opts) == 0
|
assert len(skills_opts) == 0
|
||||||
|
|
||||||
|
def test_requires_cli_is_true(self):
|
||||||
|
"""agy is a CLI tool; requires_cli must be True."""
|
||||||
|
i = get_integration(self.KEY)
|
||||||
|
assert i.config["requires_cli"] is True
|
||||||
|
|
||||||
|
def test_install_url_is_set(self):
|
||||||
|
"""install_url must point to the official installation page."""
|
||||||
|
i = get_integration(self.KEY)
|
||||||
|
assert i.config["install_url"] == "https://antigravity.google/"
|
||||||
|
|
||||||
|
|
||||||
class TestAgyAutoPromote:
|
class TestAgyAutoPromote:
|
||||||
"""--ai agy auto-promotes to integration path."""
|
"""--ai agy auto-promotes to integration path."""
|
||||||
|
|
||||||
@@ -26,7 +39,7 @@ class TestAgyAutoPromote:
|
|||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
target = tmp_path / "test-proj"
|
target = tmp_path / "test-proj"
|
||||||
result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh"])
|
result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh", "--ignore-agent-tools"])
|
||||||
|
|
||||||
assert result.exit_code == 0, f"init --ai agy failed: {result.output}"
|
assert result.exit_code == 0, f"init --ai agy failed: {result.output}"
|
||||||
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||||
@@ -36,10 +49,87 @@ class TestAgyAutoPromote:
|
|||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
# Click >= 8.2 separates stdout and stderr natively, mix_stderr is removed
|
# Click >= 8.2 separates stdout and stderr natively
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
target = tmp_path / "test-proj2"
|
target = tmp_path / "test-proj2"
|
||||||
result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh"])
|
result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh", "--ignore-agent-tools"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Warning: The .agents/ layout requires Antigravity v1.20.5 or newer" in result.stderr
|
assert "Warning: The .agents/ layout requires Antigravity v1.20.5 or newer" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgyBuildExecArgs:
|
||||||
|
"""agy non-interactive execution argument building."""
|
||||||
|
|
||||||
|
def test_build_exec_args_returns_print_command(self):
|
||||||
|
"""build_exec_args should return ['agy', '--print', prompt]."""
|
||||||
|
from specify_cli.integrations import get_integration
|
||||||
|
i = get_integration("agy")
|
||||||
|
result = i.build_exec_args("describe my feature")
|
||||||
|
assert result == ["agy", "--print", "describe my feature"]
|
||||||
|
|
||||||
|
def test_build_exec_args_ignores_model(self):
|
||||||
|
"""agy does not support --model; model param must be ignored."""
|
||||||
|
from specify_cli.integrations import get_integration
|
||||||
|
i = get_integration("agy")
|
||||||
|
result = i.build_exec_args("my prompt", model="gemini-pro")
|
||||||
|
assert result == ["agy", "--print", "my prompt"]
|
||||||
|
|
||||||
|
def test_build_exec_args_ignores_output_json(self):
|
||||||
|
"""agy does not support JSON output; output_json param must be ignored."""
|
||||||
|
from specify_cli.integrations import get_integration
|
||||||
|
i = get_integration("agy")
|
||||||
|
result = i.build_exec_args("my prompt", output_json=False)
|
||||||
|
assert result == ["agy", "--print", "my prompt"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgyHookCommandNote:
|
||||||
|
"""Verify dot-to-hyphen normalization note is injected into hook sections."""
|
||||||
|
|
||||||
|
def test_hook_note_injected_in_skills_with_hooks(self, tmp_path):
|
||||||
|
"""Skills with hook sections should contain the normalization note."""
|
||||||
|
from specify_cli.integrations import get_integration
|
||||||
|
from specify_cli.integrations.manifest import IntegrationManifest
|
||||||
|
|
||||||
|
i = get_integration("agy")
|
||||||
|
m = IntegrationManifest("agy", tmp_path)
|
||||||
|
i.setup(tmp_path, m, script_type="sh")
|
||||||
|
specify_skill = tmp_path / ".agents/skills/speckit-specify/SKILL.md"
|
||||||
|
assert specify_skill.exists()
|
||||||
|
content = specify_skill.read_text(encoding="utf-8")
|
||||||
|
assert "replace dots" in content, (
|
||||||
|
"speckit-specify should have dot-to-hyphen hook note"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_hook_note_not_in_skills_without_hooks(self):
|
||||||
|
"""Skills without hook sections should not get the note."""
|
||||||
|
from specify_cli.integrations.agy import AgyIntegration
|
||||||
|
|
||||||
|
content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n"
|
||||||
|
result = AgyIntegration._inject_hook_command_note(content)
|
||||||
|
assert "replace dots" not in result
|
||||||
|
|
||||||
|
def test_hook_note_idempotent(self):
|
||||||
|
"""Injecting the note twice must not duplicate it."""
|
||||||
|
from specify_cli.integrations.agy import AgyIntegration
|
||||||
|
|
||||||
|
content = (
|
||||||
|
"---\nname: test\n---\n\n"
|
||||||
|
"- For each executable hook, output the following based on its flag:\n"
|
||||||
|
)
|
||||||
|
once = AgyIntegration._inject_hook_command_note(content)
|
||||||
|
twice = AgyIntegration._inject_hook_command_note(once)
|
||||||
|
assert once == twice, "Hook note injection should be idempotent"
|
||||||
|
|
||||||
|
def test_hook_note_preserves_indentation(self):
|
||||||
|
"""The injected note must match the indentation of the target line."""
|
||||||
|
from specify_cli.integrations.agy import AgyIntegration
|
||||||
|
|
||||||
|
content = (
|
||||||
|
"---\nname: test\n---\n\n"
|
||||||
|
" - For each executable hook, output the following\n"
|
||||||
|
)
|
||||||
|
result = AgyIntegration._inject_hook_command_note(content)
|
||||||
|
lines = result.splitlines()
|
||||||
|
note_line = [l for l in lines if "replace dots" in l][0]
|
||||||
|
assert note_line.startswith(" "), "Note should preserve indentation"
|
||||||
|
|||||||
@@ -176,6 +176,39 @@ class SkillsIntegrationTests:
|
|||||||
f"skills agents must use /speckit-<name>"
|
f"skills agents must use /speckit-<name>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_hook_sections_explain_dotted_command_conversion(self, tmp_path):
|
||||||
|
"""Generated skills with hook sections must explain dotted command conversion."""
|
||||||
|
i = get_integration(self.KEY)
|
||||||
|
m = IntegrationManifest(self.KEY, tmp_path)
|
||||||
|
i.setup(tmp_path, m)
|
||||||
|
specify_skill = i.skills_dest(tmp_path) / "speckit-specify" / "SKILL.md"
|
||||||
|
assert specify_skill.exists()
|
||||||
|
content = specify_skill.read_text(encoding="utf-8")
|
||||||
|
assert "replace dots" in content, (
|
||||||
|
"speckit-specify should explain dotted hook command conversion"
|
||||||
|
)
|
||||||
|
assert content.count("replace dots") == content.count(
|
||||||
|
"- For each executable hook, output the following"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_hook_note_injected_for_each_instruction_independently(self):
|
||||||
|
"""Existing hook notes should not suppress later missing notes."""
|
||||||
|
content = (
|
||||||
|
"---\n"
|
||||||
|
"name: test\n"
|
||||||
|
"---\n\n"
|
||||||
|
"- When constructing slash commands from hook command names, "
|
||||||
|
"replace dots (`.`) with hyphens (`-`). "
|
||||||
|
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
|
||||||
|
"- For each executable hook, output the following first block:\n"
|
||||||
|
"\n"
|
||||||
|
"- For each executable hook, output the following second block:\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = SkillsIntegration._inject_hook_command_note(content)
|
||||||
|
|
||||||
|
assert result.count("replace dots (`.`) with hyphens") == 2
|
||||||
|
|
||||||
def test_skill_body_has_content(self, tmp_path):
|
def test_skill_body_has_content(self, tmp_path):
|
||||||
"""Each SKILL.md body should contain template content after the frontmatter."""
|
"""Each SKILL.md body should contain template content after the frontmatter."""
|
||||||
i = get_integration(self.KEY)
|
i = get_integration(self.KEY)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from unittest.mock import patch
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
|
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
|
||||||
from specify_cli.integrations.base import IntegrationBase
|
from specify_cli.integrations.base import IntegrationBase, SkillsIntegration
|
||||||
from specify_cli.integrations.claude import ARGUMENT_HINTS
|
from specify_cli.integrations.claude import ARGUMENT_HINTS
|
||||||
from specify_cli.integrations.manifest import IntegrationManifest
|
from specify_cli.integrations.manifest import IntegrationManifest
|
||||||
|
|
||||||
@@ -487,8 +487,8 @@ class TestClaudeDisableModelInvocation:
|
|||||||
assert "disable-model-invocation" not in fm
|
assert "disable-model-invocation" not in fm
|
||||||
assert "user-invocable" not in fm
|
assert "user-invocable" not in fm
|
||||||
|
|
||||||
def test_skills_default_post_process_is_identity(self, tmp_path):
|
def test_skills_default_post_process_preserves_content_without_hooks(self, tmp_path):
|
||||||
"""SkillsIntegration agents without an override leave content unchanged."""
|
"""SkillsIntegration agents without an override preserve non-hook content."""
|
||||||
# ``agy`` is a plain SkillsIntegration with no post-process override,
|
# ``agy`` is a plain SkillsIntegration with no post-process override,
|
||||||
# so it stands in for the base-class default behavior.
|
# so it stands in for the base-class default behavior.
|
||||||
agy = get_integration("agy")
|
agy = get_integration("agy")
|
||||||
@@ -505,7 +505,7 @@ class TestClaudeHookCommandNote:
|
|||||||
"""Skills that have hook sections should get the normalization note."""
|
"""Skills that have hook sections should get the normalization note."""
|
||||||
i = get_integration("claude")
|
i = get_integration("claude")
|
||||||
m = IntegrationManifest("claude", tmp_path)
|
m = IntegrationManifest("claude", tmp_path)
|
||||||
created = i.setup(tmp_path, m, script_type="sh")
|
i.setup(tmp_path, m, script_type="sh")
|
||||||
specify_skill = tmp_path / ".claude/skills/speckit-specify/SKILL.md"
|
specify_skill = tmp_path / ".claude/skills/speckit-specify/SKILL.md"
|
||||||
assert specify_skill.exists()
|
assert specify_skill.exists()
|
||||||
content = specify_skill.read_text(encoding="utf-8")
|
content = specify_skill.read_text(encoding="utf-8")
|
||||||
@@ -516,35 +516,54 @@ class TestClaudeHookCommandNote:
|
|||||||
|
|
||||||
def test_hook_note_not_in_skills_without_hooks(self, tmp_path):
|
def test_hook_note_not_in_skills_without_hooks(self, tmp_path):
|
||||||
"""Skills without hook sections should not get the note."""
|
"""Skills without hook sections should not get the note."""
|
||||||
from specify_cli.integrations.claude import ClaudeIntegration
|
|
||||||
|
|
||||||
content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n"
|
content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n"
|
||||||
result = ClaudeIntegration._inject_hook_command_note(content)
|
result = SkillsIntegration._inject_hook_command_note(content)
|
||||||
assert "replace dots" not in result
|
assert "replace dots" not in result
|
||||||
|
|
||||||
def test_hook_note_idempotent(self, tmp_path):
|
def test_hook_note_idempotent(self, tmp_path):
|
||||||
"""Injecting the note twice should not duplicate it."""
|
"""Injecting the note twice should not duplicate it."""
|
||||||
from specify_cli.integrations.claude import ClaudeIntegration
|
|
||||||
|
|
||||||
content = (
|
content = (
|
||||||
"---\nname: test\n---\n\n"
|
"---\nname: test\n---\n\n"
|
||||||
"- For each executable hook, output the following based on its flag:\n"
|
"- For each executable hook, output the following based on its flag:\n"
|
||||||
)
|
)
|
||||||
once = ClaudeIntegration._inject_hook_command_note(content)
|
once = SkillsIntegration._inject_hook_command_note(content)
|
||||||
twice = ClaudeIntegration._inject_hook_command_note(once)
|
twice = SkillsIntegration._inject_hook_command_note(once)
|
||||||
assert once == twice, "Hook note injection should be idempotent"
|
assert once == twice, "Hook note injection should be idempotent"
|
||||||
|
|
||||||
|
def test_hook_note_fills_missing_repeated_instructions(self, tmp_path):
|
||||||
|
"""Already-noted hook sections should not suppress later sections."""
|
||||||
|
from specify_cli.integrations.base import _HOOK_COMMAND_NOTE
|
||||||
|
|
||||||
|
content = (
|
||||||
|
"---\nname: test\n---\n\n"
|
||||||
|
f"{_HOOK_COMMAND_NOTE}"
|
||||||
|
"- For each executable hook, output the following based on its flag:\n"
|
||||||
|
"\n"
|
||||||
|
" - For each executable hook, output the following based on its flag:\n"
|
||||||
|
)
|
||||||
|
result = SkillsIntegration._inject_hook_command_note(content)
|
||||||
|
assert result.count("replace dots (`.`) with hyphens") == 2
|
||||||
|
|
||||||
|
def test_hook_note_not_suppressed_by_unrelated_phrase(self, tmp_path):
|
||||||
|
"""Unrelated text should not trip the hook-note idempotence guard."""
|
||||||
|
content = (
|
||||||
|
"---\nname: test\n---\n\n"
|
||||||
|
"This paragraph says replace dots in a different context.\n"
|
||||||
|
"- For each executable hook, output the following based on its flag:\n"
|
||||||
|
)
|
||||||
|
result = SkillsIntegration._inject_hook_command_note(content)
|
||||||
|
assert "This paragraph says replace dots in a different context." in result
|
||||||
|
assert result.count("replace dots (`.`) with hyphens") == 1
|
||||||
|
|
||||||
def test_hook_note_preserves_indentation(self, tmp_path):
|
def test_hook_note_preserves_indentation(self, tmp_path):
|
||||||
"""The injected note should match the indentation of the target line."""
|
"""The injected note should match the indentation of the target line."""
|
||||||
from specify_cli.integrations.claude import ClaudeIntegration
|
|
||||||
|
|
||||||
content = (
|
content = (
|
||||||
"---\nname: test\n---\n\n"
|
"---\nname: test\n---\n\n"
|
||||||
" - For each executable hook, output the following\n"
|
" - For each executable hook, output the following\n"
|
||||||
)
|
)
|
||||||
result = ClaudeIntegration._inject_hook_command_note(content)
|
result = SkillsIntegration._inject_hook_command_note(content)
|
||||||
lines = result.splitlines()
|
lines = result.splitlines()
|
||||||
note_line = [l for l in lines if "replace dots" in l][0]
|
note_line = [line for line in lines if "replace dots" in line][0]
|
||||||
assert note_line.startswith(" "), "Note should preserve indentation"
|
assert note_line.startswith(" "), "Note should preserve indentation"
|
||||||
|
|
||||||
def test_post_process_injects_all_claude_flags(self):
|
def test_post_process_injects_all_claude_flags(self):
|
||||||
|
|||||||
@@ -71,6 +71,34 @@ class TestCodexHookCommandNote:
|
|||||||
twice = CodexIntegration._inject_hook_command_note(once)
|
twice = CodexIntegration._inject_hook_command_note(once)
|
||||||
assert once == twice, "Hook note injection should be idempotent"
|
assert once == twice, "Hook note injection should be idempotent"
|
||||||
|
|
||||||
|
def test_hook_note_fills_missing_repeated_instructions(self):
|
||||||
|
"""Already-noted hook sections should not suppress later sections."""
|
||||||
|
from specify_cli.integrations.base import _HOOK_COMMAND_NOTE
|
||||||
|
from specify_cli.integrations.codex import CodexIntegration
|
||||||
|
|
||||||
|
content = (
|
||||||
|
"---\nname: test\n---\n\n"
|
||||||
|
f"{_HOOK_COMMAND_NOTE}"
|
||||||
|
"- For each executable hook, output the following based on its flag:\n"
|
||||||
|
"\n"
|
||||||
|
" - For each executable hook, output the following based on its flag:\n"
|
||||||
|
)
|
||||||
|
result = CodexIntegration._inject_hook_command_note(content)
|
||||||
|
assert result.count("replace dots (`.`) with hyphens") == 2
|
||||||
|
|
||||||
|
def test_hook_note_not_suppressed_by_unrelated_phrase(self):
|
||||||
|
"""Unrelated text should not trip the hook-note idempotence guard."""
|
||||||
|
from specify_cli.integrations.codex import CodexIntegration
|
||||||
|
|
||||||
|
content = (
|
||||||
|
"---\nname: test\n---\n\n"
|
||||||
|
"This paragraph says replace dots in a different context.\n"
|
||||||
|
"- For each executable hook, output the following based on its flag:\n"
|
||||||
|
)
|
||||||
|
result = CodexIntegration._inject_hook_command_note(content)
|
||||||
|
assert "This paragraph says replace dots in a different context." in result
|
||||||
|
assert result.count("replace dots (`.`) with hyphens") == 1
|
||||||
|
|
||||||
def test_hook_note_preserves_indentation(self):
|
def test_hook_note_preserves_indentation(self):
|
||||||
"""The injected note should match the indentation of the target line."""
|
"""The injected note should match the indentation of the target line."""
|
||||||
from specify_cli.integrations.codex import CodexIntegration
|
from specify_cli.integrations.codex import CodexIntegration
|
||||||
@@ -81,7 +109,7 @@ class TestCodexHookCommandNote:
|
|||||||
)
|
)
|
||||||
result = CodexIntegration._inject_hook_command_note(content)
|
result = CodexIntegration._inject_hook_command_note(content)
|
||||||
lines = result.splitlines()
|
lines = result.splitlines()
|
||||||
note_line = [l for l in lines if "replace dots" in l][0]
|
note_line = [line for line in lines if "replace dots" in line][0]
|
||||||
assert note_line.startswith(" "), "Note should preserve indentation"
|
assert note_line.startswith(" "), "Note should preserve indentation"
|
||||||
|
|
||||||
def test_hook_note_when_instruction_is_final_line_without_newline(self):
|
def test_hook_note_when_instruction_is_final_line_without_newline(self):
|
||||||
@@ -102,11 +130,11 @@ class TestCodexHookCommandNote:
|
|||||||
result = CodexIntegration._inject_hook_command_note(content)
|
result = CodexIntegration._inject_hook_command_note(content)
|
||||||
lines = result.splitlines()
|
lines = result.splitlines()
|
||||||
note_line_idx = next(
|
note_line_idx = next(
|
||||||
i for i, l in enumerate(lines) if "replace dots" in l
|
i for i, line in enumerate(lines) if "replace dots" in line
|
||||||
)
|
)
|
||||||
instruction_line_idx = next(
|
instruction_line_idx = next(
|
||||||
i for i, l in enumerate(lines)
|
i for i, line in enumerate(lines)
|
||||||
if l.lstrip().startswith("- For each executable hook")
|
if line.lstrip().startswith("- For each executable hook")
|
||||||
)
|
)
|
||||||
assert note_line_idx < instruction_line_idx, (
|
assert note_line_idx < instruction_line_idx, (
|
||||||
"Note must appear before the instruction"
|
"Note must appear before the instruction"
|
||||||
|
|||||||
@@ -404,6 +404,20 @@ class TestCopilotSkillsMode:
|
|||||||
updated = copilot.post_process_skill_content(content)
|
updated = copilot.post_process_skill_content(content)
|
||||||
assert "mode: speckit.plan" in updated
|
assert "mode: speckit.plan" in updated
|
||||||
|
|
||||||
|
def test_post_process_skill_content_injects_hook_note(self):
|
||||||
|
"""post_process_skill_content() should inject shared hook guidance."""
|
||||||
|
copilot = self._make_copilot()
|
||||||
|
content = (
|
||||||
|
"---\n"
|
||||||
|
'name: "speckit-specify"\n'
|
||||||
|
'description: "Specify workflow"\n'
|
||||||
|
"---\n"
|
||||||
|
"\n- For each executable hook, output the following\n"
|
||||||
|
)
|
||||||
|
updated = copilot.post_process_skill_content(content)
|
||||||
|
assert "replace dots" in updated
|
||||||
|
assert "mode: speckit.specify" in updated
|
||||||
|
|
||||||
def test_post_process_idempotent(self):
|
def test_post_process_idempotent(self):
|
||||||
"""post_process_skill_content() must be idempotent."""
|
"""post_process_skill_content() must be idempotent."""
|
||||||
copilot = self._make_copilot()
|
copilot = self._make_copilot()
|
||||||
@@ -434,6 +448,14 @@ class TestCopilotSkillsMode:
|
|||||||
stem = skill_dir_name.removeprefix("speckit-")
|
stem = skill_dir_name.removeprefix("speckit-")
|
||||||
assert fm["mode"] == f"speckit.{stem}"
|
assert fm["mode"] == f"speckit.{stem}"
|
||||||
|
|
||||||
|
def test_skills_hook_sections_explain_dotted_command_conversion(self, tmp_path):
|
||||||
|
"""Generated skills with hook sections should include shared hook guidance."""
|
||||||
|
copilot = self._make_copilot()
|
||||||
|
self._setup_skills(copilot, tmp_path)
|
||||||
|
specify_skill = tmp_path / ".github" / "skills" / "speckit-specify" / "SKILL.md"
|
||||||
|
content = specify_skill.read_text(encoding="utf-8")
|
||||||
|
assert "replace dots" in content
|
||||||
|
|
||||||
# -- Template processing ----------------------------------------------
|
# -- Template processing ----------------------------------------------
|
||||||
|
|
||||||
def test_skills_templates_are_processed(self, tmp_path):
|
def test_skills_templates_are_processed(self, tmp_path):
|
||||||
@@ -724,4 +746,4 @@ class TestCopilotSkillsMode:
|
|||||||
# Must NOT show the dotted /speckit.plan form
|
# Must NOT show the dotted /speckit.plan form
|
||||||
assert "/speckit.plan" not in result.output, (
|
assert "/speckit.plan" not in result.output, (
|
||||||
f"Should not show /speckit.plan in skills mode:\n{result.output}"
|
f"Should not show /speckit.plan in skills mode:\n{result.output}"
|
||||||
)
|
)
|
||||||
|
|||||||
347
tests/integrations/test_integration_hermes.py
Normal file
347
tests/integrations/test_integration_hermes.py
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
"""Tests for HermesIntegration.
|
||||||
|
|
||||||
|
Hermes is special among SkillsIntegration subclasses: it writes skills
|
||||||
|
to ``~/.hermes/skills/`` (global) rather than the project-local
|
||||||
|
``.hermes/skills/`` directory. A project-local marker (empty directory)
|
||||||
|
is created so extension commands (e.g. git) can detect Hermes.
|
||||||
|
|
||||||
|
All tests that touch ``~/.hermes/`` use ``monkeypatch`` to isolate
|
||||||
|
``Path.home()`` to a temp directory so the test suite is hermetic and
|
||||||
|
non-destructive to a developer's real Hermes installation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from specify_cli.integrations import get_integration
|
||||||
|
from specify_cli.integrations.manifest import IntegrationManifest
|
||||||
|
|
||||||
|
from .test_integration_base_skills import SkillsIntegrationTests
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_home(tmp_path: Path) -> Path:
|
||||||
|
"""Create and return an isolated home directory under *tmp_path*."""
|
||||||
|
home = tmp_path / "home"
|
||||||
|
home.mkdir(exist_ok=True)
|
||||||
|
return home
|
||||||
|
|
||||||
|
|
||||||
|
class TestHermesIntegration(SkillsIntegrationTests):
|
||||||
|
KEY = "hermes"
|
||||||
|
FOLDER = ".hermes/"
|
||||||
|
COMMANDS_SUBDIR = "skills"
|
||||||
|
REGISTRAR_DIR = "~/.hermes/skills"
|
||||||
|
CONTEXT_FILE = "AGENTS.md"
|
||||||
|
|
||||||
|
# -- Hermes-specific setup: skills go to ~/.hermes/skills/ -------------
|
||||||
|
|
||||||
|
def test_setup_writes_to_global_skills_dir(self, tmp_path, monkeypatch):
|
||||||
|
"""Skills are written to ~/.hermes/skills/, not project-local."""
|
||||||
|
home = _fake_home(tmp_path)
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: home)
|
||||||
|
|
||||||
|
i = get_integration(self.KEY)
|
||||||
|
m = IntegrationManifest(self.KEY, tmp_path)
|
||||||
|
created = i.setup(tmp_path, m)
|
||||||
|
skill_files = [f for f in created if "scripts" not in f.parts]
|
||||||
|
|
||||||
|
assert len(skill_files) > 0, "No skill files were created"
|
||||||
|
for f in skill_files:
|
||||||
|
# Every skill file should be under ~/.hermes/skills/speckit-*/
|
||||||
|
expected_prefix = str(home / ".hermes" / "skills")
|
||||||
|
assert str(f).startswith(expected_prefix), (
|
||||||
|
f"{f} is not under ~/.hermes/skills/"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_local_marker_dir_created(self, tmp_path, monkeypatch):
|
||||||
|
"""Project-local .hermes/skills/ should exist but be empty."""
|
||||||
|
home = _fake_home(tmp_path)
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: home)
|
||||||
|
|
||||||
|
i = get_integration(self.KEY)
|
||||||
|
m = IntegrationManifest(self.KEY, tmp_path)
|
||||||
|
i.setup(tmp_path, m)
|
||||||
|
marker = tmp_path / ".hermes" / "skills"
|
||||||
|
assert marker.is_dir(), "Marker directory was not created"
|
||||||
|
# Should be empty (no SKILL.md files)
|
||||||
|
children = list(marker.iterdir())
|
||||||
|
assert children == [], f"Marker directory should be empty, got: {children}"
|
||||||
|
|
||||||
|
# -- Override shared tests that assume project-local skills ------------
|
||||||
|
|
||||||
|
def test_setup_writes_to_correct_directory(self, tmp_path, monkeypatch):
|
||||||
|
"""Override: Hermes writes to global, not project-local."""
|
||||||
|
self.test_setup_writes_to_global_skills_dir(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
def test_plan_references_correct_context_file(self, tmp_path, monkeypatch):
|
||||||
|
"""Plan skill goes to global dir, but we check it still references AGENTS.md."""
|
||||||
|
home = _fake_home(tmp_path)
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: home)
|
||||||
|
|
||||||
|
i = get_integration(self.KEY)
|
||||||
|
if not i.context_file:
|
||||||
|
return
|
||||||
|
m = IntegrationManifest(self.KEY, tmp_path)
|
||||||
|
i.setup(tmp_path, m)
|
||||||
|
# Find the plan skill in global ~/.hermes/skills/
|
||||||
|
plan_file = home / ".hermes" / "skills" / "speckit-plan" / "SKILL.md"
|
||||||
|
assert plan_file.exists(), f"Plan skill {plan_file} not created globally"
|
||||||
|
content = plan_file.read_text(encoding="utf-8")
|
||||||
|
assert i.context_file in content, (
|
||||||
|
f"Plan skill should reference {i.context_file!r} but it was not found"
|
||||||
|
)
|
||||||
|
assert "__CONTEXT_FILE__" not in content, (
|
||||||
|
"Plan skill has unprocessed __CONTEXT_FILE__ placeholder"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_all_files_tracked_in_manifest(self, tmp_path, monkeypatch):
|
||||||
|
"""Override: Hermes does not track skills in the project manifest
|
||||||
|
since they live globally. Only project-local files (scripts,
|
||||||
|
templates, context) are tracked."""
|
||||||
|
home = _fake_home(tmp_path)
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: home)
|
||||||
|
|
||||||
|
i = get_integration(self.KEY)
|
||||||
|
m = IntegrationManifest(self.KEY, tmp_path)
|
||||||
|
created = i.setup(tmp_path, m)
|
||||||
|
for f in created:
|
||||||
|
# Global files (in ~/.hermes/) are not tracked in manifest
|
||||||
|
if str(f).startswith(str(home)):
|
||||||
|
continue
|
||||||
|
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
|
||||||
|
assert rel in m.files, f"{rel} not tracked in manifest"
|
||||||
|
|
||||||
|
def test_install_uninstall_roundtrip(self, tmp_path, monkeypatch):
|
||||||
|
"""Override: Hermes uninstall removes global skills + local marker."""
|
||||||
|
home = _fake_home(tmp_path)
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: home)
|
||||||
|
|
||||||
|
i = get_integration(self.KEY)
|
||||||
|
m = IntegrationManifest(self.KEY, tmp_path)
|
||||||
|
created = i.install(tmp_path, m)
|
||||||
|
assert len(created) > 0
|
||||||
|
m.save()
|
||||||
|
# All SKILL.md files should exist globally
|
||||||
|
for f in created:
|
||||||
|
if "SKILL.md" in str(f):
|
||||||
|
assert f.exists(), f"{f} does not exist"
|
||||||
|
# Global skills are removed on teardown without needing force
|
||||||
|
removed, skipped = i.teardown(tmp_path, m, force=False)
|
||||||
|
for f in created:
|
||||||
|
if "SKILL.md" in str(f):
|
||||||
|
assert not f.exists(), f"{f} should have been removed"
|
||||||
|
# Local marker should be gone
|
||||||
|
assert not (tmp_path / ".hermes" / "skills").exists()
|
||||||
|
|
||||||
|
def test_modified_file_survives_uninstall(self, tmp_path, monkeypatch):
|
||||||
|
"""Override: Hermes global skills are ALWAYS removed on uninstall
|
||||||
|
(they live outside the project root and aren't hash-tracked in the
|
||||||
|
manifest), so a modified global skill is still removed — matching
|
||||||
|
the standard behaviour where all integration files are cleaned up."""
|
||||||
|
home = _fake_home(tmp_path)
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: home)
|
||||||
|
|
||||||
|
i = get_integration(self.KEY)
|
||||||
|
m = IntegrationManifest(self.KEY, tmp_path)
|
||||||
|
created = i.install(tmp_path, m)
|
||||||
|
m.save()
|
||||||
|
# Pick a global skill file
|
||||||
|
skill_files = [f for f in created if "SKILL.md" in str(f)]
|
||||||
|
assert len(skill_files) > 0
|
||||||
|
modified_file = skill_files[0]
|
||||||
|
modified_file.write_text("user modified this", encoding="utf-8")
|
||||||
|
removed, skipped = i.uninstall(tmp_path, m)
|
||||||
|
assert not modified_file.exists(), (
|
||||||
|
"Modified global skill should be removed on teardown (standard behaviour)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_modified_global_skill_removed_on_teardown(self, tmp_path, monkeypatch):
|
||||||
|
"""Override: Hermes global skills are removed on uninstall regardless
|
||||||
|
of the force flag, matching standard integration behaviour."""
|
||||||
|
home = _fake_home(tmp_path)
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: home)
|
||||||
|
|
||||||
|
i = get_integration(self.KEY)
|
||||||
|
m = IntegrationManifest(self.KEY, tmp_path)
|
||||||
|
created = i.install(tmp_path, m)
|
||||||
|
m.save()
|
||||||
|
# Pick a global skill file
|
||||||
|
skill_files = [f for f in created if "SKILL.md" in str(f)]
|
||||||
|
assert len(skill_files) > 0
|
||||||
|
modified_file = skill_files[0]
|
||||||
|
modified_file.write_text("user modified this", encoding="utf-8")
|
||||||
|
# Global skills are removed on teardown regardless of force flag
|
||||||
|
removed, skipped = i.teardown(tmp_path, m, force=False)
|
||||||
|
assert not modified_file.exists(), (
|
||||||
|
"Modified global skill should be removed on teardown (standard behaviour)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pre_existing_skills_not_removed(self, tmp_path, monkeypatch):
|
||||||
|
"""Pre-existing non-speckit global skills should survive Hermes uninstall."""
|
||||||
|
home = _fake_home(tmp_path)
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: home)
|
||||||
|
|
||||||
|
i = get_integration(self.KEY)
|
||||||
|
# Create a foreign skill in the global dir first
|
||||||
|
global_skills_dir = i._hermes_home_skills_dir()
|
||||||
|
foreign_dir = global_skills_dir / "other-tool"
|
||||||
|
foreign_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(foreign_dir / "SKILL.md").write_text("# Foreign skill\n")
|
||||||
|
|
||||||
|
m = IntegrationManifest(self.KEY, tmp_path)
|
||||||
|
i.setup(tmp_path, m)
|
||||||
|
|
||||||
|
# Run teardown to verify foreign skill survives uninstall
|
||||||
|
i.teardown(tmp_path, m)
|
||||||
|
|
||||||
|
assert (foreign_dir / "SKILL.md").exists(), (
|
||||||
|
"Foreign skill was removed by teardown"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_hook_sections_explain_dotted_command_conversion(self, tmp_path, monkeypatch):
|
||||||
|
"""Override: Hermes skills live in global ~/.hermes/skills/."""
|
||||||
|
home = _fake_home(tmp_path)
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: home)
|
||||||
|
|
||||||
|
i = get_integration(self.KEY)
|
||||||
|
m = IntegrationManifest(self.KEY, tmp_path)
|
||||||
|
i.setup(tmp_path, m)
|
||||||
|
specify_skill = home / ".hermes" / "skills" / "speckit-specify" / "SKILL.md"
|
||||||
|
assert specify_skill.exists()
|
||||||
|
content = specify_skill.read_text(encoding="utf-8")
|
||||||
|
assert "replace dots" in content, (
|
||||||
|
"speckit-specify should explain dotted hook command conversion"
|
||||||
|
)
|
||||||
|
assert content.count("replace dots") == content.count(
|
||||||
|
"- For each executable hook, output the following"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_complete_file_inventory_sh(self, tmp_path, monkeypatch):
|
||||||
|
"""Override: Hermes init produces no local SKILL.md files,
|
||||||
|
only the empty .hermes/skills/ marker."""
|
||||||
|
home = _fake_home(tmp_path)
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: home)
|
||||||
|
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
from specify_cli import app
|
||||||
|
|
||||||
|
project = tmp_path / f"inventory-sh-{self.KEY}"
|
||||||
|
project.mkdir()
|
||||||
|
old_cwd = Path.cwd()
|
||||||
|
import os
|
||||||
|
try:
|
||||||
|
os.chdir(project)
|
||||||
|
result = CliRunner().invoke(app, [
|
||||||
|
"init", "--here", "--integration", self.KEY,
|
||||||
|
"--script", "sh", "--no-git", "--ignore-agent-tools",
|
||||||
|
], 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()
|
||||||
|
)
|
||||||
|
# Ensure no .hermes/skills/speckit-*/SKILL.md in project dir
|
||||||
|
hermes_skill_files = [f for f in actual if f.startswith(".hermes/skills/speckit-")]
|
||||||
|
assert hermes_skill_files == [], (
|
||||||
|
f"Expected no local SKILL.md files, found: {hermes_skill_files}"
|
||||||
|
)
|
||||||
|
# Ensure the marker exists (empty dir won't appear in file listing)
|
||||||
|
assert (project / ".hermes" / "skills").is_dir()
|
||||||
|
|
||||||
|
def test_complete_file_inventory_ps(self, tmp_path, monkeypatch):
|
||||||
|
"""Override: Same as sh variant but for PowerShell script type."""
|
||||||
|
home = _fake_home(tmp_path)
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: home)
|
||||||
|
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
from specify_cli import app
|
||||||
|
|
||||||
|
project = tmp_path / f"inventory-ps-{self.KEY}"
|
||||||
|
project.mkdir()
|
||||||
|
old_cwd = Path.cwd()
|
||||||
|
import os
|
||||||
|
try:
|
||||||
|
os.chdir(project)
|
||||||
|
result = CliRunner().invoke(app, [
|
||||||
|
"init", "--here", "--integration", self.KEY,
|
||||||
|
"--script", "ps", "--no-git", "--ignore-agent-tools",
|
||||||
|
], 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()
|
||||||
|
)
|
||||||
|
hermes_skill_files = [f for f in actual if f.startswith(".hermes/skills/speckit-")]
|
||||||
|
assert hermes_skill_files == [], (
|
||||||
|
f"Expected no local SKILL.md files, found: {hermes_skill_files}"
|
||||||
|
)
|
||||||
|
assert (project / ".hermes" / "skills").is_dir()
|
||||||
|
|
||||||
|
def test_install_uninstall_cleanup(self, tmp_path, monkeypatch):
|
||||||
|
"""Verify global skills are cleaned and local marker is removed."""
|
||||||
|
home = _fake_home(tmp_path)
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: home)
|
||||||
|
|
||||||
|
i = get_integration(self.KEY)
|
||||||
|
m = IntegrationManifest(self.KEY, tmp_path)
|
||||||
|
created = i.setup(tmp_path, m)
|
||||||
|
|
||||||
|
# Verify global skills exist
|
||||||
|
global_skills = [
|
||||||
|
f for f in created
|
||||||
|
if "SKILL.md" in str(f)
|
||||||
|
and str(f).startswith(str(home / ".hermes"))
|
||||||
|
]
|
||||||
|
assert len(global_skills) > 0
|
||||||
|
for f in global_skills:
|
||||||
|
assert f.exists()
|
||||||
|
|
||||||
|
# Verify local marker exists
|
||||||
|
assert (tmp_path / ".hermes" / "skills").is_dir()
|
||||||
|
|
||||||
|
# Teardown — global skills removed without needing force=True
|
||||||
|
removed, skipped = i.teardown(tmp_path, m, force=False)
|
||||||
|
|
||||||
|
# Global skills removed
|
||||||
|
for f in global_skills:
|
||||||
|
assert not f.exists(), f"{f} should have been removed"
|
||||||
|
|
||||||
|
# Local marker removed
|
||||||
|
assert not (tmp_path / ".hermes" / "skills").exists(), (
|
||||||
|
"Local marker should be removed on teardown"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHermesAutoPromote:
|
||||||
|
"""--ai hermes auto-promotes to integration path."""
|
||||||
|
|
||||||
|
def test_ai_hermes_without_ai_skills_auto_promotes(self, tmp_path, monkeypatch):
|
||||||
|
"""--ai hermes should work the same as --integration hermes,
|
||||||
|
creating global skills and a local marker."""
|
||||||
|
home = _fake_home(tmp_path)
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: home)
|
||||||
|
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
from specify_cli import app
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
target = tmp_path / "test-proj"
|
||||||
|
result = runner.invoke(app, [
|
||||||
|
"init", str(target),
|
||||||
|
"--ai", "hermes",
|
||||||
|
"--no-git",
|
||||||
|
"--ignore-agent-tools",
|
||||||
|
"--script", "sh",
|
||||||
|
])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, f"init --ai hermes failed: {result.output}"
|
||||||
|
# Skills should be in global ~/.hermes/skills/
|
||||||
|
assert (home / ".hermes" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||||
|
# Local marker should exist
|
||||||
|
assert (target / ".hermes" / "skills").is_dir()
|
||||||
|
# No SKILL.md files in project-local dir
|
||||||
|
local_skills = list((target / ".hermes" / "skills").iterdir())
|
||||||
|
assert local_skills == [], f"Local skills dir should be empty, got: {local_skills}"
|
||||||
@@ -386,6 +386,10 @@ class TestIntegrationInstall:
|
|||||||
# Shared infrastructure should be present
|
# Shared infrastructure should be present
|
||||||
assert (project / ".specify" / "scripts").is_dir()
|
assert (project / ".specify" / "scripts").is_dir()
|
||||||
assert (project / ".specify" / "templates").is_dir()
|
assert (project / ".specify" / "templates").is_dir()
|
||||||
|
script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||||
|
script_content = script.read_text(encoding="utf-8")
|
||||||
|
assert "/speckit-specify" in script_content
|
||||||
|
assert "/speckit.specify" not in script_content
|
||||||
|
|
||||||
|
|
||||||
# ── uninstall ────────────────────────────────────────────────────────
|
# ── uninstall ────────────────────────────────────────────────────────
|
||||||
@@ -514,7 +518,9 @@ class TestIntegrationUninstall:
|
|||||||
def test_uninstall_default_refreshes_templates_for_fallback(self, tmp_path):
|
def test_uninstall_default_refreshes_templates_for_fallback(self, tmp_path):
|
||||||
project = _init_project(tmp_path, "gemini")
|
project = _init_project(tmp_path, "gemini")
|
||||||
template = project / ".specify" / "templates" / "plan-template.md"
|
template = project / ".specify" / "templates" / "plan-template.md"
|
||||||
|
script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||||
assert "/speckit.plan" in template.read_text(encoding="utf-8")
|
assert "/speckit.plan" in template.read_text(encoding="utf-8")
|
||||||
|
assert "/speckit.plan" in script.read_text(encoding="utf-8")
|
||||||
|
|
||||||
old_cwd = os.getcwd()
|
old_cwd = os.getcwd()
|
||||||
try:
|
try:
|
||||||
@@ -533,6 +539,7 @@ class TestIntegrationUninstall:
|
|||||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||||
assert data["integration"] == "claude"
|
assert data["integration"] == "claude"
|
||||||
assert "/speckit-plan" in template.read_text(encoding="utf-8")
|
assert "/speckit-plan" in template.read_text(encoding="utf-8")
|
||||||
|
assert "/speckit-plan" in script.read_text(encoding="utf-8")
|
||||||
|
|
||||||
def test_uninstall_preserves_shared_infra(self, tmp_path):
|
def test_uninstall_preserves_shared_infra(self, tmp_path):
|
||||||
"""Shared scripts and templates are not removed by integration uninstall."""
|
"""Shared scripts and templates are not removed by integration uninstall."""
|
||||||
@@ -593,7 +600,9 @@ class TestIntegrationUse:
|
|||||||
def test_use_refreshes_shared_templates_between_command_styles(self, tmp_path):
|
def test_use_refreshes_shared_templates_between_command_styles(self, tmp_path):
|
||||||
project = _init_project(tmp_path, "claude")
|
project = _init_project(tmp_path, "claude")
|
||||||
template = project / ".specify" / "templates" / "plan-template.md"
|
template = project / ".specify" / "templates" / "plan-template.md"
|
||||||
|
script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||||
assert "/speckit-plan" in template.read_text(encoding="utf-8")
|
assert "/speckit-plan" in template.read_text(encoding="utf-8")
|
||||||
|
assert "/speckit-plan" in script.read_text(encoding="utf-8")
|
||||||
|
|
||||||
old_cwd = os.getcwd()
|
old_cwd = os.getcwd()
|
||||||
try:
|
try:
|
||||||
@@ -607,10 +616,14 @@ class TestIntegrationUse:
|
|||||||
use_gemini = runner.invoke(app, ["integration", "use", "gemini"], catch_exceptions=False)
|
use_gemini = runner.invoke(app, ["integration", "use", "gemini"], catch_exceptions=False)
|
||||||
assert use_gemini.exit_code == 0, use_gemini.output
|
assert use_gemini.exit_code == 0, use_gemini.output
|
||||||
assert "/speckit.plan" in template.read_text(encoding="utf-8")
|
assert "/speckit.plan" in template.read_text(encoding="utf-8")
|
||||||
|
assert "/speckit.plan" in script.read_text(encoding="utf-8")
|
||||||
|
assert "/speckit-plan" not in script.read_text(encoding="utf-8")
|
||||||
|
|
||||||
use_claude = runner.invoke(app, ["integration", "use", "claude"], catch_exceptions=False)
|
use_claude = runner.invoke(app, ["integration", "use", "claude"], catch_exceptions=False)
|
||||||
assert use_claude.exit_code == 0, use_claude.output
|
assert use_claude.exit_code == 0, use_claude.output
|
||||||
assert "/speckit-plan" in template.read_text(encoding="utf-8")
|
assert "/speckit-plan" in template.read_text(encoding="utf-8")
|
||||||
|
assert "/speckit-plan" in script.read_text(encoding="utf-8")
|
||||||
|
assert "/speckit.plan" not in script.read_text(encoding="utf-8")
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
|
|
||||||
@@ -630,6 +643,8 @@ class TestIntegrationUse:
|
|||||||
|
|
||||||
use_gemini = runner.invoke(app, ["integration", "use", "gemini"], catch_exceptions=False)
|
use_gemini = runner.invoke(app, ["integration", "use", "gemini"], catch_exceptions=False)
|
||||||
assert use_gemini.exit_code == 0, use_gemini.output
|
assert use_gemini.exit_code == 0, use_gemini.output
|
||||||
|
normalized = " ".join(use_gemini.output.split())
|
||||||
|
assert "specify integration use gemini --force" in normalized
|
||||||
assert template.read_text(encoding="utf-8") == "custom template with /speckit-plan\n"
|
assert template.read_text(encoding="utf-8") == "custom template with /speckit-plan\n"
|
||||||
|
|
||||||
force_use = runner.invoke(app, [
|
force_use = runner.invoke(app, [
|
||||||
@@ -644,8 +659,7 @@ class TestIntegrationUse:
|
|||||||
assert "/speckit.plan" in updated
|
assert "/speckit.plan" in updated
|
||||||
assert "custom template" not in updated
|
assert "custom template" not in updated
|
||||||
|
|
||||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
def test_use_does_not_persist_default_when_shared_infra_refresh_fails(self, tmp_path, monkeypatch):
|
||||||
def test_use_does_not_persist_default_when_template_refresh_fails(self, tmp_path):
|
|
||||||
project = _init_project(tmp_path, "claude")
|
project = _init_project(tmp_path, "claude")
|
||||||
int_json = project / ".specify" / "integration.json"
|
int_json = project / ".specify" / "integration.json"
|
||||||
init_options = project / ".specify" / "init-options.json"
|
init_options = project / ".specify" / "init-options.json"
|
||||||
@@ -661,12 +675,12 @@ class TestIntegrationUse:
|
|||||||
|
|
||||||
before_state = json.loads(int_json.read_text(encoding="utf-8"))
|
before_state = json.loads(int_json.read_text(encoding="utf-8"))
|
||||||
before_options = json.loads(init_options.read_text(encoding="utf-8"))
|
before_options = json.loads(init_options.read_text(encoding="utf-8"))
|
||||||
|
import specify_cli
|
||||||
|
|
||||||
outside = tmp_path / "outside-template.md"
|
def fail_refresh(*args, **kwargs):
|
||||||
outside.write_text("# outside\n", encoding="utf-8")
|
raise ValueError("refuse refresh")
|
||||||
template = project / ".specify" / "templates" / "plan-template.md"
|
|
||||||
template.unlink()
|
monkeypatch.setattr(specify_cli, "_install_shared_infra", fail_refresh)
|
||||||
os.symlink(outside, template)
|
|
||||||
|
|
||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, [
|
||||||
"integration", "use", "codex",
|
"integration", "use", "codex",
|
||||||
@@ -676,10 +690,9 @@ class TestIntegrationUse:
|
|||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
|
|
||||||
assert result.exit_code != 0
|
assert result.exit_code != 0
|
||||||
assert "Failed to refresh shared templates" in result.output
|
assert "Failed to refresh shared infrastructure" in result.output
|
||||||
assert json.loads(int_json.read_text(encoding="utf-8")) == before_state
|
assert json.loads(int_json.read_text(encoding="utf-8")) == before_state
|
||||||
assert json.loads(init_options.read_text(encoding="utf-8")) == before_options
|
assert json.loads(init_options.read_text(encoding="utf-8")) == before_options
|
||||||
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
|
||||||
|
|
||||||
|
|
||||||
# ── switch ───────────────────────────────────────────────────────────
|
# ── switch ───────────────────────────────────────────────────────────
|
||||||
@@ -737,7 +750,9 @@ class TestIntegrationSwitch:
|
|||||||
def test_switch_same_force_refreshes_shared_templates(self, tmp_path):
|
def test_switch_same_force_refreshes_shared_templates(self, tmp_path):
|
||||||
project = _init_project(tmp_path, "claude")
|
project = _init_project(tmp_path, "claude")
|
||||||
template = project / ".specify" / "templates" / "plan-template.md"
|
template = project / ".specify" / "templates" / "plan-template.md"
|
||||||
|
script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||||
template.write_text("# custom shared template\n", encoding="utf-8")
|
template.write_text("# custom shared template\n", encoding="utf-8")
|
||||||
|
script.write_text("# custom shared script\n", encoding="utf-8")
|
||||||
|
|
||||||
old_cwd = os.getcwd()
|
old_cwd = os.getcwd()
|
||||||
try:
|
try:
|
||||||
@@ -749,8 +764,10 @@ class TestIntegrationSwitch:
|
|||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
assert result.exit_code == 0, result.output
|
assert result.exit_code == 0, result.output
|
||||||
assert "managed shared templates refreshed" in result.output
|
assert "shared infrastructure refreshed" in result.output
|
||||||
|
assert "managed shared infrastructure refreshed" not in result.output
|
||||||
assert "/speckit-plan" in template.read_text(encoding="utf-8")
|
assert "/speckit-plan" in template.read_text(encoding="utf-8")
|
||||||
|
assert "/speckit-plan" in script.read_text(encoding="utf-8")
|
||||||
|
|
||||||
def test_switch_installed_target_rejects_integration_options(self, tmp_path):
|
def test_switch_installed_target_rejects_integration_options(self, tmp_path):
|
||||||
project = _init_project(tmp_path, "claude")
|
project = _init_project(tmp_path, "claude")
|
||||||
@@ -779,6 +796,8 @@ class TestIntegrationSwitch:
|
|||||||
project = _init_project(tmp_path, "claude")
|
project = _init_project(tmp_path, "claude")
|
||||||
# Verify claude files exist (claude uses skills)
|
# Verify claude files exist (claude uses skills)
|
||||||
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||||
|
shared_script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||||
|
assert "/speckit-specify" in shared_script.read_text(encoding="utf-8")
|
||||||
|
|
||||||
old_cwd = os.getcwd()
|
old_cwd = os.getcwd()
|
||||||
try:
|
try:
|
||||||
@@ -797,6 +816,8 @@ class TestIntegrationSwitch:
|
|||||||
|
|
||||||
# New copilot files created
|
# New copilot files created
|
||||||
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
|
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
|
||||||
|
assert "/speckit.specify" in shared_script.read_text(encoding="utf-8")
|
||||||
|
assert "/speckit-specify" not in shared_script.read_text(encoding="utf-8")
|
||||||
|
|
||||||
# integration.json updated
|
# integration.json updated
|
||||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||||
@@ -938,12 +959,13 @@ class TestIntegrationSwitch:
|
|||||||
assert "claude" not in git_meta["registered_commands"]
|
assert "claude" not in git_meta["registered_commands"]
|
||||||
assert "opencode" not in git_meta["registered_commands"]
|
assert "opencode" not in git_meta["registered_commands"]
|
||||||
|
|
||||||
def test_switch_preserves_shared_infra(self, tmp_path):
|
def test_switch_refreshes_managed_shared_script_refs(self, tmp_path):
|
||||||
"""Switching preserves shared scripts, templates, and memory."""
|
"""Switching refreshes managed shared scripts to the target command style."""
|
||||||
project = _init_project(tmp_path, "claude")
|
project = _init_project(tmp_path, "claude")
|
||||||
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
||||||
assert shared_script.exists()
|
assert shared_script.exists()
|
||||||
shared_content = shared_script.read_text(encoding="utf-8")
|
shared_content = shared_script.read_text(encoding="utf-8")
|
||||||
|
assert "/speckit-plan" in shared_content
|
||||||
|
|
||||||
old_cwd = os.getcwd()
|
old_cwd = os.getcwd()
|
||||||
try:
|
try:
|
||||||
@@ -956,9 +978,10 @@ class TestIntegrationSwitch:
|
|||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
# Shared infra untouched
|
|
||||||
assert shared_script.exists()
|
assert shared_script.exists()
|
||||||
assert shared_script.read_text(encoding="utf-8") == shared_content
|
updated = shared_script.read_text(encoding="utf-8")
|
||||||
|
assert "/speckit.plan" in updated
|
||||||
|
assert "/speckit-plan" not in updated
|
||||||
|
|
||||||
def test_switch_refreshes_stale_managed_shared_infra(self, tmp_path):
|
def test_switch_refreshes_stale_managed_shared_infra(self, tmp_path):
|
||||||
"""Regression for #2293: stale managed shared scripts get refreshed on switch."""
|
"""Regression for #2293: stale managed shared scripts get refreshed on switch."""
|
||||||
@@ -966,7 +989,7 @@ class TestIntegrationSwitch:
|
|||||||
|
|
||||||
project = _init_project(tmp_path, "claude")
|
project = _init_project(tmp_path, "claude")
|
||||||
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
||||||
bundled_bytes = shared_script.read_bytes()
|
assert "/speckit-plan" in shared_script.read_text(encoding="utf-8")
|
||||||
|
|
||||||
# Simulate a stale vendored script: write truncated content as bytes
|
# Simulate a stale vendored script: write truncated content as bytes
|
||||||
# (write_text would translate \n→\r\n on Windows and break the hash)
|
# (write_text would translate \n→\r\n on Windows and break the hash)
|
||||||
@@ -993,8 +1016,11 @@ class TestIntegrationSwitch:
|
|||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
# Stale managed file should be replaced by the bundled version
|
# Stale managed file should be replaced by the target integration's rendered version.
|
||||||
assert shared_script.read_bytes() == bundled_bytes
|
updated = shared_script.read_text(encoding="utf-8")
|
||||||
|
assert "# stale vendored copy" not in updated
|
||||||
|
assert "/speckit.plan" in updated
|
||||||
|
assert "/speckit-plan" not in updated
|
||||||
|
|
||||||
def test_switch_preserves_user_customized_shared_infra(self, tmp_path):
|
def test_switch_preserves_user_customized_shared_infra(self, tmp_path):
|
||||||
"""User customizations (hash divergence from manifest) survive switch without --refresh-shared-infra."""
|
"""User customizations (hash divergence from manifest) survive switch without --refresh-shared-infra."""
|
||||||
@@ -1024,10 +1050,11 @@ class TestIntegrationSwitch:
|
|||||||
"""--refresh-shared-infra explicitly overwrites user customizations on switch."""
|
"""--refresh-shared-infra explicitly overwrites user customizations on switch."""
|
||||||
project = _init_project(tmp_path, "claude")
|
project = _init_project(tmp_path, "claude")
|
||||||
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
||||||
bundled_bytes = shared_script.read_bytes()
|
assert "/speckit-plan" in shared_script.read_text(encoding="utf-8")
|
||||||
|
rendered_bytes = shared_script.read_bytes()
|
||||||
|
|
||||||
# User customization (hash diverges from manifest)
|
# User customization (hash diverges from manifest)
|
||||||
custom_bytes = bundled_bytes + b"\n# user customization\n"
|
custom_bytes = rendered_bytes + b"\n# user customization\n"
|
||||||
shared_script.write_bytes(custom_bytes)
|
shared_script.write_bytes(custom_bytes)
|
||||||
|
|
||||||
old_cwd = os.getcwd()
|
old_cwd = os.getcwd()
|
||||||
@@ -1041,8 +1068,11 @@ class TestIntegrationSwitch:
|
|||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
# Customization is overwritten with the bundled version
|
# Customization is overwritten with the target integration's rendered version.
|
||||||
assert shared_script.read_bytes() == bundled_bytes
|
updated = shared_script.read_text(encoding="utf-8")
|
||||||
|
assert "# user customization" not in updated
|
||||||
|
assert "/speckit.plan" in updated
|
||||||
|
assert "/speckit-plan" not in updated
|
||||||
|
|
||||||
def test_switch_skips_symlinked_parent_directory(self, tmp_path):
|
def test_switch_skips_symlinked_parent_directory(self, tmp_path):
|
||||||
"""Regression: if .specify/scripts/bash is a symlink, switch must not write through it.
|
"""Regression: if .specify/scripts/bash is a symlink, switch must not write through it.
|
||||||
@@ -1222,7 +1252,7 @@ class TestIntegrationUpgrade:
|
|||||||
assert updated["ai"] == "gemini"
|
assert updated["ai"] == "gemini"
|
||||||
assert updated["context_file"] == "GEMINI.md"
|
assert updated["context_file"] == "GEMINI.md"
|
||||||
|
|
||||||
def test_upgrade_does_not_persist_state_when_template_refresh_fails(self, tmp_path, monkeypatch):
|
def test_upgrade_does_not_persist_state_when_shared_infra_refresh_fails(self, tmp_path, monkeypatch):
|
||||||
project = _init_project(tmp_path, "claude")
|
project = _init_project(tmp_path, "claude")
|
||||||
int_json = project / ".specify" / "integration.json"
|
int_json = project / ".specify" / "integration.json"
|
||||||
init_options = project / ".specify" / "init-options.json"
|
init_options = project / ".specify" / "init-options.json"
|
||||||
@@ -1234,10 +1264,16 @@ class TestIntegrationUpgrade:
|
|||||||
|
|
||||||
import specify_cli
|
import specify_cli
|
||||||
|
|
||||||
def fail_refresh(*args, **kwargs):
|
real_install_shared_infra = specify_cli._install_shared_infra
|
||||||
raise ValueError("refuse refresh")
|
calls = {"count": 0}
|
||||||
|
|
||||||
monkeypatch.setattr(specify_cli, "_refresh_shared_templates", fail_refresh)
|
def fail_refresh(*args, **kwargs):
|
||||||
|
calls["count"] += 1
|
||||||
|
if calls["count"] == 2:
|
||||||
|
raise ValueError("refuse refresh")
|
||||||
|
return real_install_shared_infra(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(specify_cli, "_install_shared_infra", fail_refresh)
|
||||||
|
|
||||||
result = _run_in_project(project, [
|
result = _run_in_project(project, [
|
||||||
"integration", "upgrade", "claude",
|
"integration", "upgrade", "claude",
|
||||||
@@ -1245,15 +1281,40 @@ class TestIntegrationUpgrade:
|
|||||||
])
|
])
|
||||||
|
|
||||||
assert result.exit_code != 0
|
assert result.exit_code != 0
|
||||||
assert "Failed to refresh shared templates" in result.output
|
assert "Failed to refresh shared infrastructure" in result.output
|
||||||
assert json.loads(int_json.read_text(encoding="utf-8")) == before_state
|
assert json.loads(int_json.read_text(encoding="utf-8")) == before_state
|
||||||
assert json.loads(init_options.read_text(encoding="utf-8")) == before_options
|
assert json.loads(init_options.read_text(encoding="utf-8")) == before_options
|
||||||
assert manifest_path.read_text(encoding="utf-8") == before_manifest
|
assert manifest_path.read_text(encoding="utf-8") == before_manifest
|
||||||
|
|
||||||
|
def test_upgrade_default_refreshes_shared_script_refs_for_option_separator_change(self, tmp_path):
|
||||||
|
project = _init_project(tmp_path, "copilot")
|
||||||
|
template = project / ".specify" / "templates" / "plan-template.md"
|
||||||
|
managed_script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||||
|
customized_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||||
|
|
||||||
|
assert "/speckit.plan" in template.read_text(encoding="utf-8")
|
||||||
|
assert "/speckit.specify" in managed_script.read_text(encoding="utf-8")
|
||||||
|
customized_before = customized_script.read_text(encoding="utf-8") + "\n# user customization\n"
|
||||||
|
customized_script.write_text(customized_before, encoding="utf-8")
|
||||||
|
|
||||||
|
result = _run_in_project(project, [
|
||||||
|
"integration", "upgrade", "copilot",
|
||||||
|
"--integration-options", "--skills",
|
||||||
|
])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "/speckit-plan" in template.read_text(encoding="utf-8")
|
||||||
|
managed_content = managed_script.read_text(encoding="utf-8")
|
||||||
|
assert "/speckit-specify" in managed_content
|
||||||
|
assert "/speckit.specify" not in managed_content
|
||||||
|
assert customized_script.read_text(encoding="utf-8") == customized_before
|
||||||
|
|
||||||
def test_upgrade_non_default_keeps_default_template_invocations(self, tmp_path):
|
def test_upgrade_non_default_keeps_default_template_invocations(self, tmp_path):
|
||||||
project = _init_project(tmp_path, "gemini")
|
project = _init_project(tmp_path, "gemini")
|
||||||
template = project / ".specify" / "templates" / "plan-template.md"
|
template = project / ".specify" / "templates" / "plan-template.md"
|
||||||
|
script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||||
assert "/speckit.plan" in template.read_text(encoding="utf-8")
|
assert "/speckit.plan" in template.read_text(encoding="utf-8")
|
||||||
|
assert "/speckit.plan" in script.read_text(encoding="utf-8")
|
||||||
|
|
||||||
old_cwd = os.getcwd()
|
old_cwd = os.getcwd()
|
||||||
try:
|
try:
|
||||||
@@ -1276,6 +1337,8 @@ class TestIntegrationUpgrade:
|
|||||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||||
assert data["integration"] == "gemini"
|
assert data["integration"] == "gemini"
|
||||||
assert "/speckit.plan" in template.read_text(encoding="utf-8")
|
assert "/speckit.plan" in template.read_text(encoding="utf-8")
|
||||||
|
assert "/speckit.plan" in script.read_text(encoding="utf-8")
|
||||||
|
assert "/speckit-plan" not in script.read_text(encoding="utf-8")
|
||||||
|
|
||||||
def test_upgrade_migrates_opencode_legacy_dir(self, tmp_path):
|
def test_upgrade_migrates_opencode_legacy_dir(self, tmp_path):
|
||||||
"""Upgrade moves OpenCode commands from .opencode/command/ to .opencode/commands/."""
|
"""Upgrade moves OpenCode commands from .opencode/command/ to .opencode/commands/."""
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ Tests cover:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import pytest
|
import pytest
|
||||||
import tempfile
|
import tempfile
|
||||||
import shutil
|
import shutil
|
||||||
@@ -116,6 +117,18 @@ def _create_extension_dir(temp_dir: Path, ext_id: str = "test-ext") -> Path:
|
|||||||
return ext_dir
|
return ext_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _can_create_symlink(temp_dir: Path) -> bool:
|
||||||
|
"""Return True when the current platform/user can create file symlinks."""
|
||||||
|
target = temp_dir / "symlink-target.txt"
|
||||||
|
link = temp_dir / "symlink-link.txt"
|
||||||
|
target.write_text("ok", encoding="utf-8")
|
||||||
|
try:
|
||||||
|
os.symlink(target, link)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return link.is_symlink()
|
||||||
|
|
||||||
|
|
||||||
# ===== Fixtures =====
|
# ===== Fixtures =====
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -324,6 +337,149 @@ class TestExtensionSkillRegistration:
|
|||||||
# The pre-existing one should NOT be in registered_skills (it was skipped)
|
# The pre-existing one should NOT be in registered_skills (it was skipped)
|
||||||
assert "speckit-test-ext-hello" not in metadata["registered_skills"]
|
assert "speckit-test-ext-hello" not in metadata["registered_skills"]
|
||||||
|
|
||||||
|
def test_dev_skill_symlink_refreshes_existing_cache(
|
||||||
|
self, skills_project, extension_dir, temp_dir
|
||||||
|
):
|
||||||
|
"""Dev-mode skill symlinks should refresh rendered cache content."""
|
||||||
|
if not _can_create_symlink(temp_dir):
|
||||||
|
pytest.skip("Current platform/user cannot create symlinks")
|
||||||
|
|
||||||
|
project_dir, skills_dir = skills_project
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
||||||
|
|
||||||
|
manager._register_extension_skills(
|
||||||
|
manifest,
|
||||||
|
extension_dir,
|
||||||
|
link_outputs=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
|
||||||
|
assert skill_file.is_symlink()
|
||||||
|
assert "Run this to say hello." in skill_file.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
(extension_dir / "commands" / "hello.md").write_text(
|
||||||
|
"---\n"
|
||||||
|
"description: \"Updated test hello command\"\n"
|
||||||
|
"---\n"
|
||||||
|
"\n"
|
||||||
|
"# Hello Command\n"
|
||||||
|
"\n"
|
||||||
|
"Run this updated hello.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
written = manager._register_extension_skills(
|
||||||
|
manifest,
|
||||||
|
extension_dir,
|
||||||
|
link_outputs=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "speckit-test-ext-hello" in written
|
||||||
|
assert "Run this updated hello." in skill_file.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def test_dev_skill_registration_falls_back_to_copy_when_symlink_fails(
|
||||||
|
self, skills_project, extension_dir, monkeypatch
|
||||||
|
):
|
||||||
|
"""Dev-mode skill registration works when Windows cannot create symlinks."""
|
||||||
|
project_dir, skills_dir = skills_project
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
||||||
|
|
||||||
|
def raise_windows_symlink_error(target, link):
|
||||||
|
raise OSError("A required privilege is not held by the client")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"specify_cli.extensions.os.symlink", raise_windows_symlink_error
|
||||||
|
)
|
||||||
|
|
||||||
|
written = manager._register_extension_skills(
|
||||||
|
manifest,
|
||||||
|
extension_dir,
|
||||||
|
link_outputs=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
|
||||||
|
assert "speckit-test-ext-hello" in written
|
||||||
|
assert skill_file.exists()
|
||||||
|
assert not skill_file.is_symlink()
|
||||||
|
assert "Run this to say hello." in skill_file.read_text(encoding="utf-8")
|
||||||
|
assert (
|
||||||
|
extension_dir
|
||||||
|
/ ".specify-dev"
|
||||||
|
/ "extension-skills"
|
||||||
|
/ "speckit-test-ext-hello"
|
||||||
|
/ "SKILL.md"
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
def test_dev_skill_registration_falls_back_to_copy_when_relpath_fails(
|
||||||
|
self, skills_project, extension_dir, monkeypatch
|
||||||
|
):
|
||||||
|
"""Dev-mode skill registration stays functional across Windows drive roots."""
|
||||||
|
project_dir, skills_dir = skills_project
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
||||||
|
|
||||||
|
def raise_relpath_error(path, start=None):
|
||||||
|
raise ValueError("path is on mount 'D:', start on mount 'C:'")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"specify_cli.extensions.os.path.relpath", raise_relpath_error
|
||||||
|
)
|
||||||
|
|
||||||
|
written = manager._register_extension_skills(
|
||||||
|
manifest,
|
||||||
|
extension_dir,
|
||||||
|
link_outputs=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
|
||||||
|
assert "speckit-test-ext-hello" in written
|
||||||
|
assert skill_file.exists()
|
||||||
|
assert not skill_file.is_symlink()
|
||||||
|
assert "Run this to say hello." in skill_file.read_text(encoding="utf-8")
|
||||||
|
assert (
|
||||||
|
extension_dir
|
||||||
|
/ ".specify-dev"
|
||||||
|
/ "extension-skills"
|
||||||
|
/ "speckit-test-ext-hello"
|
||||||
|
/ "SKILL.md"
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
def test_dev_skill_registration_falls_back_to_copy_when_cache_write_fails(
|
||||||
|
self, skills_project, extension_dir, monkeypatch
|
||||||
|
):
|
||||||
|
"""Dev-mode skill registration stays functional when the dev cache is unwritable."""
|
||||||
|
project_dir, skills_dir = skills_project
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
||||||
|
original_write_text = Path.write_text
|
||||||
|
|
||||||
|
def raise_cache_write_error(path, *args, **kwargs):
|
||||||
|
if ".specify-dev" in path.parts:
|
||||||
|
raise OSError("cache is not writable")
|
||||||
|
return original_write_text(path, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(Path, "write_text", raise_cache_write_error)
|
||||||
|
|
||||||
|
written = manager._register_extension_skills(
|
||||||
|
manifest,
|
||||||
|
extension_dir,
|
||||||
|
link_outputs=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
|
||||||
|
assert "speckit-test-ext-hello" in written
|
||||||
|
assert skill_file.exists()
|
||||||
|
assert not skill_file.is_symlink()
|
||||||
|
assert "Run this to say hello." in skill_file.read_text(encoding="utf-8")
|
||||||
|
assert not (
|
||||||
|
extension_dir
|
||||||
|
/ ".specify-dev"
|
||||||
|
/ "extension-skills"
|
||||||
|
/ "speckit-test-ext-hello"
|
||||||
|
/ "SKILL.md"
|
||||||
|
).exists()
|
||||||
|
|
||||||
def test_registered_skills_in_registry(self, skills_project, extension_dir):
|
def test_registered_skills_in_registry(self, skills_project, extension_dir):
|
||||||
"""Registry should contain registered_skills list."""
|
"""Registry should contain registered_skills list."""
|
||||||
project_dir, skills_dir = skills_project
|
project_dir, skills_dir = skills_project
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ Tests cover:
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import platform
|
import platform
|
||||||
import tempfile
|
import tempfile
|
||||||
import shutil
|
import shutil
|
||||||
@@ -36,6 +37,18 @@ from specify_cli.extensions import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def can_create_symlink(tmp_path: Path) -> bool:
|
||||||
|
"""Return True when the current platform/user can create file symlinks."""
|
||||||
|
target = tmp_path / "symlink-target.txt"
|
||||||
|
link = tmp_path / "symlink-link.txt"
|
||||||
|
target.write_text("ok", encoding="utf-8")
|
||||||
|
try:
|
||||||
|
os.symlink(target, link)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return link.is_symlink()
|
||||||
|
|
||||||
|
|
||||||
# ===== Fixtures =====
|
# ===== Fixtures =====
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -1722,6 +1735,168 @@ Run {SCRIPT}
|
|||||||
assert "description: Test hello command" in content
|
assert "description: Test hello command" in content
|
||||||
assert "test-ext" in content
|
assert "test-ext" in content
|
||||||
|
|
||||||
|
def test_dev_register_commands_symlinks_rendered_copilot_agent(
|
||||||
|
self, extension_dir, project_dir, temp_dir
|
||||||
|
):
|
||||||
|
"""Dev-mode registration should symlink agent files to rendered outputs."""
|
||||||
|
if not can_create_symlink(temp_dir):
|
||||||
|
pytest.skip("Current platform/user cannot create symlinks")
|
||||||
|
|
||||||
|
agents_dir = project_dir / ".github" / "agents"
|
||||||
|
agents_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
||||||
|
registrar = CommandRegistrar()
|
||||||
|
registered = registrar.register_commands_for_agent(
|
||||||
|
"copilot",
|
||||||
|
manifest,
|
||||||
|
extension_dir,
|
||||||
|
project_dir,
|
||||||
|
link_outputs=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert registered == ["speckit.test-ext.hello"]
|
||||||
|
|
||||||
|
cmd_file = agents_dir / "speckit.test-ext.hello.agent.md"
|
||||||
|
assert cmd_file.is_symlink()
|
||||||
|
|
||||||
|
target = cmd_file.resolve()
|
||||||
|
assert ".specify-dev" in target.parts
|
||||||
|
assert target.is_file()
|
||||||
|
assert "Extension: test-ext" in cmd_file.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def test_dev_register_commands_falls_back_to_copy_when_symlink_fails(
|
||||||
|
self, extension_dir, project_dir, monkeypatch
|
||||||
|
):
|
||||||
|
"""Dev-mode registration stays functional when symlinks are unavailable."""
|
||||||
|
agents_dir = project_dir / ".github" / "agents"
|
||||||
|
agents_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
def raise_symlink_error(target, link):
|
||||||
|
raise OSError("symlink unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr("specify_cli.agents.os.symlink", raise_symlink_error)
|
||||||
|
|
||||||
|
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
||||||
|
registrar = CommandRegistrar()
|
||||||
|
registrar.register_commands_for_agent(
|
||||||
|
"copilot",
|
||||||
|
manifest,
|
||||||
|
extension_dir,
|
||||||
|
project_dir,
|
||||||
|
link_outputs=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd_file = agents_dir / "speckit.test-ext.hello.agent.md"
|
||||||
|
assert cmd_file.exists()
|
||||||
|
assert not cmd_file.is_symlink()
|
||||||
|
assert "Extension: test-ext" in cmd_file.read_text(encoding="utf-8")
|
||||||
|
assert (
|
||||||
|
extension_dir
|
||||||
|
/ ".specify-dev"
|
||||||
|
/ "agent-commands"
|
||||||
|
/ "copilot"
|
||||||
|
/ "speckit.test-ext.hello.agent.md"
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
def test_dev_register_commands_falls_back_to_copy_when_relpath_fails(
|
||||||
|
self, extension_dir, project_dir, monkeypatch
|
||||||
|
):
|
||||||
|
"""Dev-mode registration stays functional across Windows drive roots."""
|
||||||
|
agents_dir = project_dir / ".github" / "agents"
|
||||||
|
agents_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
def raise_relpath_error(path, start=None):
|
||||||
|
raise ValueError("path is on mount 'D:', start on mount 'C:'")
|
||||||
|
|
||||||
|
monkeypatch.setattr("specify_cli.agents.os.path.relpath", raise_relpath_error)
|
||||||
|
|
||||||
|
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
||||||
|
registrar = CommandRegistrar()
|
||||||
|
registrar.register_commands_for_agent(
|
||||||
|
"copilot",
|
||||||
|
manifest,
|
||||||
|
extension_dir,
|
||||||
|
project_dir,
|
||||||
|
link_outputs=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd_file = agents_dir / "speckit.test-ext.hello.agent.md"
|
||||||
|
assert cmd_file.exists()
|
||||||
|
assert not cmd_file.is_symlink()
|
||||||
|
assert "Extension: test-ext" in cmd_file.read_text(encoding="utf-8")
|
||||||
|
assert (
|
||||||
|
extension_dir
|
||||||
|
/ ".specify-dev"
|
||||||
|
/ "agent-commands"
|
||||||
|
/ "copilot"
|
||||||
|
/ "speckit.test-ext.hello.agent.md"
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
def test_dev_register_commands_falls_back_to_copy_when_cache_write_fails(
|
||||||
|
self, extension_dir, project_dir, monkeypatch
|
||||||
|
):
|
||||||
|
"""Dev-mode registration stays functional when the dev cache is unwritable."""
|
||||||
|
agents_dir = project_dir / ".github" / "agents"
|
||||||
|
agents_dir.mkdir(parents=True)
|
||||||
|
original_write_text = Path.write_text
|
||||||
|
|
||||||
|
def raise_cache_write_error(path, *args, **kwargs):
|
||||||
|
if ".specify-dev" in path.parts:
|
||||||
|
raise OSError("cache is not writable")
|
||||||
|
return original_write_text(path, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(Path, "write_text", raise_cache_write_error)
|
||||||
|
|
||||||
|
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
||||||
|
registrar = CommandRegistrar()
|
||||||
|
registrar.register_commands_for_agent(
|
||||||
|
"copilot",
|
||||||
|
manifest,
|
||||||
|
extension_dir,
|
||||||
|
project_dir,
|
||||||
|
link_outputs=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd_file = agents_dir / "speckit.test-ext.hello.agent.md"
|
||||||
|
assert cmd_file.exists()
|
||||||
|
assert not cmd_file.is_symlink()
|
||||||
|
assert "Extension: test-ext" in cmd_file.read_text(encoding="utf-8")
|
||||||
|
assert not (
|
||||||
|
extension_dir
|
||||||
|
/ ".specify-dev"
|
||||||
|
/ "agent-commands"
|
||||||
|
/ "copilot"
|
||||||
|
/ "speckit.test-ext.hello.agent.md"
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
def test_dev_register_commands_rejects_cache_path_traversal(self, temp_dir):
|
||||||
|
"""Dev-mode cache writes must stay inside the agent cache root."""
|
||||||
|
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||||
|
|
||||||
|
source_dir = temp_dir / "extension"
|
||||||
|
source_dir.mkdir()
|
||||||
|
commands_dir = temp_dir / "commands"
|
||||||
|
commands_dir.mkdir()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="escapes directory"):
|
||||||
|
AgentCommandRegistrar._write_registered_output(
|
||||||
|
commands_dir / "safe.md",
|
||||||
|
"content",
|
||||||
|
source_dir,
|
||||||
|
"copilot",
|
||||||
|
"../escaped",
|
||||||
|
".md",
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not (
|
||||||
|
source_dir
|
||||||
|
/ ".specify-dev"
|
||||||
|
/ "agent-commands"
|
||||||
|
/ "escaped.md"
|
||||||
|
).exists()
|
||||||
|
|
||||||
def test_copilot_companion_prompt_created(self, extension_dir, project_dir):
|
def test_copilot_companion_prompt_created(self, extension_dir, project_dir):
|
||||||
"""Test that companion .prompt.md files are created in .github/prompts/."""
|
"""Test that companion .prompt.md files are created in .github/prompts/."""
|
||||||
agents_dir = project_dir / ".github" / "agents"
|
agents_dir = project_dir / ".github" / "agents"
|
||||||
@@ -3458,6 +3633,86 @@ class TestExtensionIgnore:
|
|||||||
class TestExtensionAddCLI:
|
class TestExtensionAddCLI:
|
||||||
"""CLI integration tests for extension add command."""
|
"""CLI integration tests for extension add command."""
|
||||||
|
|
||||||
|
def test_add_dev_links_copilot_agent_when_supported(
|
||||||
|
self, extension_dir, project_dir, temp_dir
|
||||||
|
):
|
||||||
|
"""extension add --dev should link generated agent files when possible."""
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
from unittest.mock import patch
|
||||||
|
from specify_cli import app
|
||||||
|
|
||||||
|
(project_dir / ".github" / "agents").mkdir(parents=True)
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
with patch.object(Path, "cwd", return_value=project_dir):
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["extension", "add", str(extension_dir), "--dev"],
|
||||||
|
catch_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
|
||||||
|
agent_file = (
|
||||||
|
project_dir
|
||||||
|
/ ".github"
|
||||||
|
/ "agents"
|
||||||
|
/ "speckit.test-ext.hello.agent.md"
|
||||||
|
)
|
||||||
|
assert agent_file.exists()
|
||||||
|
if can_create_symlink(temp_dir):
|
||||||
|
assert agent_file.is_symlink()
|
||||||
|
assert ".specify-dev" in agent_file.resolve().parts
|
||||||
|
else:
|
||||||
|
assert not agent_file.is_symlink()
|
||||||
|
|
||||||
|
def test_add_dev_falls_back_to_copy_when_windows_symlinks_unavailable(
|
||||||
|
self, extension_dir, project_dir, monkeypatch
|
||||||
|
):
|
||||||
|
"""extension add --dev should work when Windows cannot create symlinks."""
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
from unittest.mock import patch
|
||||||
|
from specify_cli import app
|
||||||
|
|
||||||
|
(project_dir / ".github" / "agents").mkdir(parents=True)
|
||||||
|
|
||||||
|
def raise_windows_symlink_error(target, link):
|
||||||
|
raise OSError("A required privilege is not held by the client")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"specify_cli.agents.os.symlink", raise_windows_symlink_error
|
||||||
|
)
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
with patch.object(Path, "cwd", return_value=project_dir):
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["extension", "add", str(extension_dir), "--dev"],
|
||||||
|
catch_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
|
||||||
|
agent_file = (
|
||||||
|
project_dir
|
||||||
|
/ ".github"
|
||||||
|
/ "agents"
|
||||||
|
/ "speckit.test-ext.hello.agent.md"
|
||||||
|
)
|
||||||
|
assert agent_file.exists()
|
||||||
|
assert not agent_file.is_symlink()
|
||||||
|
assert "Extension: test-ext" in agent_file.read_text(encoding="utf-8")
|
||||||
|
assert (
|
||||||
|
project_dir
|
||||||
|
/ ".specify"
|
||||||
|
/ "extensions"
|
||||||
|
/ "test-ext"
|
||||||
|
/ ".specify-dev"
|
||||||
|
/ "agent-commands"
|
||||||
|
/ "copilot"
|
||||||
|
/ "speckit.test-ext.hello.agent.md"
|
||||||
|
).exists()
|
||||||
|
|
||||||
def test_add_by_display_name_uses_resolved_id_for_download(self, tmp_path):
|
def test_add_by_display_name_uses_resolved_id_for_download(self, tmp_path):
|
||||||
"""extension add by display name should use resolved ID for download_extension()."""
|
"""extension add by display name should use resolved ID for download_extension()."""
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
@@ -3744,13 +3999,20 @@ class TestExtensionUpdateCLI:
|
|||||||
).read_text()
|
).read_text()
|
||||||
assert restored_config_content == original_config_content
|
assert restored_config_content == original_config_content
|
||||||
|
|
||||||
def test_update_failure_rolls_back_registry_hooks_and_commands(self, tmp_path):
|
def test_update_failure_rolls_back_registry_hooks_and_commands(self, tmp_path, monkeypatch):
|
||||||
"""Failed update should restore original registry, hooks, and command files."""
|
"""Failed update should restore original registry, hooks, and command files."""
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
# Isolate home directory so Hermes' global ~/.hermes/skills/ doesn't
|
||||||
|
# interfere — without a real skills dir, Hermes is skipped during
|
||||||
|
# command registration, keeping the test focused on Claude/Codex/etc.
|
||||||
|
fake_home = tmp_path / "home"
|
||||||
|
fake_home.mkdir()
|
||||||
|
monkeypatch.setattr(Path, "home", lambda: fake_home)
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
project_dir = tmp_path / "project"
|
project_dir = tmp_path / "project"
|
||||||
project_dir.mkdir()
|
project_dir.mkdir()
|
||||||
@@ -3772,7 +4034,9 @@ class TestExtensionUpdateCLI:
|
|||||||
if agent_name not in agent_registrar.AGENT_CONFIGS:
|
if agent_name not in agent_registrar.AGENT_CONFIGS:
|
||||||
continue
|
continue
|
||||||
agent_cfg = agent_registrar.AGENT_CONFIGS[agent_name]
|
agent_cfg = agent_registrar.AGENT_CONFIGS[agent_name]
|
||||||
commands_dir = project_dir / agent_cfg["dir"]
|
commands_dir = AgentRegistrar._resolve_agent_dir(
|
||||||
|
agent_name, agent_cfg, project_dir
|
||||||
|
)
|
||||||
for cmd_name in cmd_names:
|
for cmd_name in cmd_names:
|
||||||
output_name = AgentRegistrar._compute_output_name(agent_name, cmd_name, agent_cfg)
|
output_name = AgentRegistrar._compute_output_name(agent_name, cmd_name, agent_cfg)
|
||||||
cmd_path = commands_dir / f"{output_name}{agent_cfg['extension']}"
|
cmd_path = commands_dir / f"{output_name}{agent_cfg['extension']}"
|
||||||
|
|||||||
Reference in New Issue
Block a user