mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80ac47e257 |
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -5,3 +5,4 @@
|
||||
/extensions/catalog.community.json @mnriem
|
||||
/integrations/catalog.community.json @mnriem
|
||||
/presets/catalog.community.json @mnriem
|
||||
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/agent_request.yml
vendored
2
.github/ISSUE_TEMPLATE/agent_request.yml
vendored
@@ -7,7 +7,7 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for requesting a new agent! Before submitting, please check if the agent is already supported.
|
||||
|
||||
|
||||
**Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
|
||||
|
||||
- type: input
|
||||
|
||||
@@ -7,7 +7,7 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for contributing an extension! This template helps you submit your extension to the community catalog.
|
||||
|
||||
|
||||
**Before submitting:**
|
||||
- Review the [Extension Publishing Guide](https://github.com/github/spec-kit/blob/main/extensions/EXTENSION-PUBLISHING-GUIDE.md)
|
||||
- Ensure your extension has a valid `extension.yml` manifest
|
||||
@@ -209,9 +209,9 @@ body:
|
||||
**Tested on:**
|
||||
- macOS 14.0 with Spec Kit v0.1.0
|
||||
- Linux Ubuntu 22.04 with Spec Kit v0.1.0
|
||||
|
||||
|
||||
**Test project:** [Link or description]
|
||||
|
||||
|
||||
**Test scenarios:**
|
||||
1. Installed extension
|
||||
2. Configured settings
|
||||
@@ -230,7 +230,7 @@ body:
|
||||
```bash
|
||||
# Install extension
|
||||
specify extension add <extension-name> --from https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip
|
||||
|
||||
|
||||
# Use a command
|
||||
/speckit.your-extension.command-name arg1 arg2
|
||||
```
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/preset_submission.yml
vendored
2
.github/ISSUE_TEMPLATE/preset_submission.yml
vendored
@@ -7,7 +7,7 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for contributing a preset! This template helps you submit your preset to the community catalog.
|
||||
|
||||
|
||||
**Before submitting:**
|
||||
- Review the [Preset Publishing Guide](https://github.com/github/spec-kit/blob/main/presets/PUBLISHING.md)
|
||||
- Ensure your preset has a valid `preset.yml` manifest
|
||||
|
||||
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -19,3 +19,4 @@
|
||||
- [ ] I **did** use AI assistance (describe below)
|
||||
|
||||
<!-- If you used AI, briefly describe how (e.g., "Code generated by Copilot", "Consulted ChatGPT for approach"): -->
|
||||
|
||||
|
||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Run markdownlint-cli2
|
||||
uses: DavidAnson/markdownlint-cli2-action@8de2aa07cae85fd17c0b35642db70cf5495f1d25 # v24.0.0
|
||||
uses: DavidAnson/markdownlint-cli2-action@ded1f9488f68a970bc66ea5619e13e9b52e601cd # v23
|
||||
with:
|
||||
globs: |
|
||||
'**/*.md'
|
||||
|
||||
4
.github/workflows/publish-pypi.yml
vendored
4
.github/workflows/publish-pypi.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
path: dist/
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
|
||||
- name: Publish to PyPI
|
||||
run: uv publish
|
||||
|
||||
10
.github/workflows/stale.yml
vendored
10
.github/workflows/stale.yml
vendored
@@ -20,24 +20,24 @@ jobs:
|
||||
days-before-stale: 150
|
||||
# Days of inactivity before a stale issue or PR is closed (after being marked stale)
|
||||
days-before-close: 30
|
||||
|
||||
|
||||
# Stale issue settings
|
||||
stale-issue-message: 'This issue has been automatically marked as stale because it has not had any activity for 150 days. It will be closed in 30 days if no further activity occurs.'
|
||||
close-issue-message: 'This issue has been automatically closed due to inactivity (180 days total). If you believe this issue is still relevant, please reopen it or create a new issue.'
|
||||
stale-issue-label: 'stale'
|
||||
|
||||
|
||||
# Stale PR settings
|
||||
stale-pr-message: 'This pull request has been automatically marked as stale because it has not had any activity for 150 days. It will be closed in 30 days if no further activity occurs.'
|
||||
close-pr-message: 'This pull request has been automatically closed due to inactivity (180 days total). If you believe this PR is still relevant, please reopen it or create a new PR.'
|
||||
stale-pr-label: 'stale'
|
||||
|
||||
|
||||
# Exempt issues and PRs with these labels from being marked as stale
|
||||
exempt-issue-labels: 'pinned,security'
|
||||
exempt-pr-labels: 'pinned,security'
|
||||
|
||||
|
||||
# Only issues or PRs with all of these labels are checked
|
||||
# Leave empty to check all issues and PRs
|
||||
any-of-labels: ''
|
||||
|
||||
|
||||
# Operations per run (helps avoid rate limits)
|
||||
operations-per-run: 250
|
||||
|
||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
|
||||
@@ -26,4 +26,4 @@
|
||||
"ignores": [
|
||||
".genreleases/"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: check-executables-have-shebangs
|
||||
- id: check-yaml
|
||||
exclude: \.lock\.yml$
|
||||
- id: end-of-file-fixer
|
||||
exclude: \.lock\.yml$
|
||||
- id: trailing-whitespace
|
||||
exclude: \.lock\.yml$
|
||||
60
CHANGELOG.md
60
CHANGELOG.md
@@ -2,66 +2,6 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.12.13] - 2026-07-13
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(workflows): fail switch step on non-mapping cases instead of crashing (#3481)
|
||||
- Cleanup agent-file-template.md (#2579)
|
||||
- fix: mark Kiro integration as multi-install safe (#3472)
|
||||
- fix: rewrite extension-relative subdir paths in generated command bodies (#3444)
|
||||
- fix(templates): point constitution sync checklist at installed command files (#3418)
|
||||
- feat(workflows): make shell step timeout configurable (#3327) (#3328)
|
||||
- docs: clarify that release tags keep the leading v prefix (#3463)
|
||||
- fix(workflows): don't crash on membership test against a non-iterable (#3448)
|
||||
- fix(workflows): if-step validate accepts falsy non-list else (#3264)
|
||||
- chore: release 0.12.12, begin 0.12.13.dev0 development (#3490)
|
||||
|
||||
## [0.12.12] - 2026-07-13
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(extensions): set-priority repairs corrupted boolean priority (#3268)
|
||||
- fix(presets): set-priority repairs corrupted boolean priority (#3269)
|
||||
- fix(workflows): engine loop cap ignores bool max_iterations (#3270)
|
||||
- docs(bundles): document --integration on 'bundle update' (#3271)
|
||||
- fix(workflows): harden catalog.py against mis-shaped registry & non-string fields (#3375)
|
||||
- Add Verify Review Ship extension to community catalog (#3450)
|
||||
- fix(bundle): reject file:// / local download_url — catalog URLs are HTTPS-only (#3344)
|
||||
- fix(extensions): handle prefix-colliding env vars in _get_env_config (#3350)
|
||||
- docs: document copilot skills mode (--skills) and markdown deprecation (#3313)
|
||||
- chore: release 0.12.11, begin 0.12.12.dev0 development (#3460)
|
||||
|
||||
## [0.12.11] - 2026-07-10
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(agent-context): discover nested plan.md in scoped layouts (#3024) (#3301)
|
||||
- fix(auth): return no matches, not raw ValueError, for a malformed URL (#3437)
|
||||
- fix(catalogs): raise catalog error, not raw ValueError, on a malformed URL (#3435)
|
||||
- fix(bundler): raise BundlerError, not raw ValueError, on a malformed catalog URL (#3433)
|
||||
- chore: add pre-commit config and fix trailing whitespace/end-of-file (#3430)
|
||||
- Add EARS Requirements Syntax extension to community catalog (#3407)
|
||||
- Add Spec Kit Figma extension to community catalog (#3408)
|
||||
- fix(workflows): report validation errors instead of crashing on non-string workflow.yml scalars (#3421)
|
||||
- fix(templates): remove self-referencing path in plan-template.md note (#3417)
|
||||
- chore: release 0.12.10, begin 0.12.11.dev0 development (#3453)
|
||||
|
||||
## [0.12.10] - 2026-07-10
|
||||
|
||||
### Changed
|
||||
|
||||
- chore(deps): bump astral-sh/setup-uv from 8.2.0 to 8.3.2 (#3439)
|
||||
- chore(deps): bump DavidAnson/markdownlint-cli2-action (#3438)
|
||||
- fix(templates): correct phase numbering in plan.md (#3416)
|
||||
- fix(git-ext): honor explicit -Number 0 in PowerShell branch creation (#3412)
|
||||
- docs: add 'spectatui' entry to friends.md (#3362)
|
||||
- test: pin interpreter probe so py-template render test passes on Windows (#3428)
|
||||
- feat(workflows): make shell step timeout configurable (#3404)
|
||||
- fix: find plans in nested spec directories (#3405)
|
||||
- feat(templates): add py: lines to command templates' scripts frontmatter (#3403)
|
||||
- chore: release 0.12.9, begin 0.12.10.dev0 development (#3426)
|
||||
|
||||
## [0.12.9] - 2026-07-09
|
||||
|
||||
### Changed
|
||||
|
||||
1
LICENSE
1
LICENSE
@@ -19,3 +19,4 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ Spec-Driven Development **flips the script** on traditional software development
|
||||
|
||||
### 1. Install Specify CLI
|
||||
|
||||
Requires **[uv](https://docs.astral.sh/uv/)** ([install uv](./docs/install/uv.md)). Replace `vX.Y.Z` with the latest release tag from [Releases](https://github.com/github/spec-kit/releases) — keep the leading `v` (for example, `v0.12.11`, not `0.12.11`):
|
||||
Requires **[uv](https://docs.astral.sh/uv/)** ([install uv](./docs/install/uv.md)). Replace `vX.Y.Z` with the latest tag from [Releases](https://github.com/github/spec-kit/releases):
|
||||
|
||||
```bash
|
||||
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
|
||||
1
docs/.gitignore
vendored
1
docs/.gitignore
vendored
@@ -6,3 +6,4 @@ obj/
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.log
|
||||
|
||||
|
||||
@@ -52,7 +52,6 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) |
|
||||
| Data Model Diagram | Generates Mermaid ER diagrams from Spec Kit data models after planning | `docs` | Read+Write | [spec-kit-data-model-diagram](https://github.com/benizzio/spec-kit-data-model-diagram) |
|
||||
| DocGuard — CDD Enforcement | The only doc-integrity engine with an MCP server, SARIF output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
||||
| EARS Requirements Syntax | Author, lint, and convert requirements using EARS - the five industry-standard sentence patterns for unambiguous, testable requirements | `docs` | Read+Write | [spec-kit-ears](https://github.com/dhruv-15-03/spec-kit-ears) |
|
||||
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) |
|
||||
| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) |
|
||||
| FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) |
|
||||
@@ -118,7 +117,6 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) |
|
||||
| Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) |
|
||||
| Spec Kit Discovery Extension | Run technical discovery commands for feasibility, technology selection, scenario-specific technical decisions, legacy codebase assessment, implementation understanding, and proof-of-concept validation | `process` | Read+Write | [spec-kit-discovery](https://github.com/bigsmartben/spec-kit-discovery) |
|
||||
| Spec Kit Figma | Agent-agnostic SpecKit extension that grounds spec, plan & task generation in Figma design context — REST + optional MCP, single/mono/multi-repo, macOS/Linux/Windows. | `integration` | Read+Write | [spec-kit-figma](https://github.com/Fyloss/spec-kit-figma) |
|
||||
| Spec Kit Preview | Generate evidence-backed low, mid, or high fidelity previews from Spec Kit artifacts as Markdown or self-contained HTML | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) |
|
||||
| Spec Kit Schedule | Optimal multi-agent task scheduling via CP-SAT — DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output | `process` | Read+Write | [spec-kit-schedule](https://github.com/jfranc38/spec-kit-schedule) |
|
||||
| Spec Kit TLDR | Render a feature's spec.md / plan.md into a review-oriented TLDR (self-contained HTML dashboard + PR-native Markdown) that surfaces risks for faster PR review. | `visibility` | Read+Write | [speckit-tldr](https://github.com/qurore/speckit-tldr) |
|
||||
@@ -148,7 +146,6 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Token Economy | Token routing, measured savings, and context audit workflows | `process` | Read+Write | [spec-kit-token-economy](https://github.com/formin/spec-kit-token-economy) |
|
||||
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
|
||||
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
|
||||
| Verify Review Ship | Adds post-implementation verify, review, and ship readiness gates to Spec Kit workflows | `process` | Read-only | [spec-kit-verify-review-ship](https://github.com/cadugevaerd/spec-kit-verify-review-ship) |
|
||||
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
|
||||
| Version Guard | Verify tech stack versions against live npm registries before planning and implementation | `process` | Read-only | [spec-kit-version-guard](https://github.com/KevinBrown5280/spec-kit-version-guard) |
|
||||
| What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) |
|
||||
|
||||
@@ -14,5 +14,3 @@ Community projects that extend, visualize, or build on Spec Kit:
|
||||
- **[SpecKit Companion](https://marketplace.visualstudio.com/items?itemName=alfredoperez.speckit-companion)** — A VS Code extension that brings a visual GUI to Spec Kit. Browse specs in a rich markdown viewer with clickable file references, create specifications with image attachments, comment and refine each step inline (GitHub-style review), track your progress through the SDD workflow with a visual phase stepper, and manage steering documents like constitutions and templates.
|
||||
|
||||
- **[cc-spec-kit](https://github.com/speckit-community/cc-spec-kit)** — Community-maintained plugin for Claude Code and GitHub Copilot CLI that installs Spec Kit skills via the plugin marketplace.
|
||||
|
||||
- **[spectatui](https://github.com/tinesoft/spectatui)** — A terminal UI (TUI) dashboard for Spec Kit that lets you track features, manage specifications, integrations, presets, workflows, and extensions, and monitor AI agent workflows. Attach to existing AI sessions or launch new ones from your terminal. Keyboard and mouse support. Light/dark theme support. Customizable and performance-oriented. Requires the `specify` CLI in your PATH.
|
||||
|
||||
@@ -11,8 +11,7 @@ If you want to try Spec Kit without installing it permanently, use `uvx` to run
|
||||
# Create a new project (latest from main)
|
||||
uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME>
|
||||
|
||||
# Or target a specific release (replace vX.Y.Z with a tag from Releases;
|
||||
# keep the leading v, e.g. v0.12.11 not 0.12.11)
|
||||
# Or target a specific release (replace vX.Y.Z with a tag from Releases)
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <PROJECT_NAME>
|
||||
|
||||
# Initialize in the current directory
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest):
|
||||
|
||||
```bash
|
||||
# Install a specific stable release (recommended — replace vX.Y.Z with the
|
||||
# latest tag, keeping the leading v, e.g. v0.12.11 not 0.12.11)
|
||||
# Install a specific stable release (recommended — replace vX.Y.Z with the latest tag)
|
||||
pipx install git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
|
||||
# Or install latest from main (may include unreleased changes)
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
### Persistent Installation (Recommended)
|
||||
|
||||
Install once and use everywhere. Replace `vX.Y.Z` with a release tag from [Releases](https://github.com/github/spec-kit/releases) — keep the leading `v` (for example, `v0.12.11`, not `0.12.11`):
|
||||
Install once and use everywhere. Replace `vX.Y.Z` with a tag from [Releases](https://github.com/github/spec-kit/releases):
|
||||
|
||||
> [!NOTE]
|
||||
> The command below requires **[uv](https://docs.astral.sh/uv/)**. If you see `command not found: uv`, [install uv first](./install/uv.md).
|
||||
|
||||
@@ -51,11 +51,10 @@ If the current directory is not yet a Spec Kit project, `install` initializes on
|
||||
specify bundle update [<bundle_id>]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ---------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--all` | Update every installed bundle |
|
||||
| `--integration` | Override the integration used when refreshing components; applied only when the project's active integration can't be determined |
|
||||
| `--offline` | Do not access the network |
|
||||
| Option | Description |
|
||||
| ------------ | ------------------------------------ |
|
||||
| `--all` | Update every installed bundle |
|
||||
| `--offline` | Do not access the network |
|
||||
|
||||
Re-resolves a bundle and **refreshes** its components through each primitive's update path, bringing already-installed components up to the bundle's newly pinned versions while preserving primitive-level overrides (such as preset priority). Provide a bundle id, or use `--all` to update everything installed.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
|
||||
| [Firebender](https://firebender.com/) | `firebender` | IDE-based agent for Android Studio / IntelliJ |
|
||||
| [Forge](https://forgecode.dev/) | `forge` | |
|
||||
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | |
|
||||
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | Defaults to legacy markdown mode: `.agent.md` command files under `.github/agents/`, companion `.prompt.md` files under `.github/prompts/`, and a `.vscode/settings.json` merge. Pass `--integration-options="--skills"` to scaffold skills as `speckit-<command>/SKILL.md` under `.github/skills/` instead. Legacy markdown mode is deprecated and will stop being the default in a future release. |
|
||||
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | |
|
||||
| [Goose](https://goose-docs.ai/) | `goose` | Uses YAML recipe format in `.goose/recipes/` |
|
||||
| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` |
|
||||
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent |
|
||||
@@ -219,7 +219,6 @@ Some integrations accept additional options via `--integration-options`:
|
||||
| ----------- | ------------------- | -------------------------------------------------------------- |
|
||||
| `generic` | `--commands-dir` | Required. Directory for command files |
|
||||
| `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs to `.kimi-code/skills/` (including dotted→hyphenated skill naming, e.g. `speckit.xxx` → `speckit-xxx`) |
|
||||
| `copilot` | `--skills` | Scaffold commands as agent skills (`speckit-<command>/SKILL.md` under `.github/skills/`, invoked as `/speckit-<command>`) instead of the default legacy markdown mode (`.github/agents/*.agent.md` plus `.github/prompts/*.prompt.md` and a `.vscode/settings.json` merge). Without this flag, install warns that legacy markdown mode is deprecated. |
|
||||
|
||||
Example:
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ The script reads the agent-context extension config at
|
||||
- `context_files` — optional project-relative paths for multiple coding agent context files. When non-empty, the script updates each listed file and the list takes precedence over `context_file`.
|
||||
- `context_markers.start` / `.end` — the delimiters surrounding the managed section. Defaults to `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` when the field is missing.
|
||||
|
||||
It then creates, replaces, or appends the managed block so that the section points at the most recent plan path when one can be discovered (any `plan.md` under `specs/`, including nested scoped layouts such as `specs/<scope>/<feature>/plan.md`).
|
||||
It then creates, replaces, or appends the managed block so that the section points at the most recent plan path when one can be discovered (`specs/<feature>/plan.md`).
|
||||
|
||||
If `context_files` and `context_file` are empty, the command reports nothing to do and exits successfully. Context file paths must stay project-relative; absolute paths, Windows drive paths, backslash separators, and `..` path segments are rejected.
|
||||
|
||||
@@ -24,4 +24,4 @@ If `context_files` and `context_file` are empty, the command reports nothing to
|
||||
- **Bash**: `.specify/extensions/agent-context/scripts/bash/update-agent-context.sh [plan_path]`
|
||||
- **PowerShell**: `.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 [plan_path]`
|
||||
|
||||
When `plan_path` is omitted, the script auto-detects the most recently modified `specs/**/plan.md` (searched recursively, so nested scoped layouts are discovered).
|
||||
When `plan_path` is omitted, the script auto-detects the most recently modified `specs/*/plan.md`.
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
#
|
||||
# When `plan_path` is omitted, the script derives it from `.specify/feature.json`
|
||||
# (written by /speckit-specify). Falls back to the most recently modified
|
||||
# `specs/**/plan.md` only when feature.json is absent or its plan does not exist yet.
|
||||
# `specs/*/plan.md` only when feature.json is absent or its plan does not exist yet.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -307,28 +307,16 @@ import sys
|
||||
from pathlib import Path
|
||||
root = Path(sys.argv[1]).resolve()
|
||||
specs = root / "specs"
|
||||
|
||||
def _resolved_rel(p):
|
||||
# Resolve symlinks before checking containment: relative_to() is lexical
|
||||
# and would otherwise accept a plan reached through a specs/ symlink that
|
||||
# points outside the project, emitting an in-project-looking path for an
|
||||
# out-of-project file (or picking it as "most recent").
|
||||
plans = sorted(
|
||||
specs.glob("*/plan.md"),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
if plans:
|
||||
try:
|
||||
return p.resolve().relative_to(root)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
# Recurse (rather than the old one-level specs/*/plan.md glob) so scoped layouts
|
||||
# created via SPECIFY_FEATURE_DIRECTORY, e.g. specs/<scope>/<feature>/plan.md,
|
||||
# are still discovered when feature.json is absent (#3024).
|
||||
candidates = []
|
||||
for p in specs.rglob("plan.md"):
|
||||
rel = _resolved_rel(p)
|
||||
if rel:
|
||||
candidates.append((p, rel))
|
||||
candidates.sort(key=lambda pr: pr[0].stat().st_mtime, reverse=True)
|
||||
if candidates:
|
||||
print(candidates[0][1].as_posix())
|
||||
print(plans[0].relative_to(root).as_posix())
|
||||
except ValueError:
|
||||
print("")
|
||||
else:
|
||||
print("")
|
||||
PY
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
#
|
||||
# When `plan_path` is omitted, the script derives it from `.specify/feature.json`
|
||||
# (written by /speckit-specify). Falls back to the most recently modified
|
||||
# `specs/**/plan.md` only when feature.json is absent or its plan does not exist yet.
|
||||
# `specs/*/plan.md` only when feature.json is absent or its plan does not exist yet.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
@@ -426,11 +426,9 @@ if (-not $PlanPath) {
|
||||
if (-not $PlanPath) {
|
||||
try {
|
||||
$specsDir = Join-Path $ProjectRoot 'specs'
|
||||
# Recurse (rather than the old one-level specs/*/plan.md scan) so scoped
|
||||
# layouts created via SPECIFY_FEATURE_DIRECTORY, e.g.
|
||||
# specs/<scope>/<feature>/plan.md, are still discovered when
|
||||
# feature.json is absent (#3024).
|
||||
$candidate = Get-ChildItem -Path $specsDir -Filter 'plan.md' -File -Recurse -ErrorAction SilentlyContinue |
|
||||
$candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } |
|
||||
Where-Object { $_ } |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
if ($candidate) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-10T00:00:00Z",
|
||||
"updated_at": "2026-07-07T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -1180,39 +1180,6 @@
|
||||
"created_at": "2026-03-13T00:00:00Z",
|
||||
"updated_at": "2026-03-13T00:00:00Z"
|
||||
},
|
||||
"ears": {
|
||||
"name": "EARS Requirements Syntax",
|
||||
"id": "ears",
|
||||
"description": "Author, lint, and convert requirements using EARS (Easy Approach to Requirements Syntax) - the five industry-standard sentence patterns for unambiguous, testable requirements.",
|
||||
"author": "dhruv-15-03",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/dhruv-15-03/spec-kit-ears/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/dhruv-15-03/spec-kit-ears",
|
||||
"homepage": "https://github.com/dhruv-15-03/spec-kit-ears",
|
||||
"documentation": "https://github.com/dhruv-15-03/spec-kit-ears/blob/main/README.md",
|
||||
"changelog": "https://github.com/dhruv-15-03/spec-kit-ears/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "docs",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.9.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"ears",
|
||||
"requirements",
|
||||
"specification",
|
||||
"quality"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-08T00:00:00Z",
|
||||
"updated_at": "2026-07-08T00:00:00Z"
|
||||
},
|
||||
"extensify": {
|
||||
"name": "Extensify",
|
||||
"id": "extensify",
|
||||
@@ -1246,47 +1213,6 @@
|
||||
"created_at": "2026-03-18T00:00:00Z",
|
||||
"updated_at": "2026-04-23T00:00:00Z"
|
||||
},
|
||||
"figma": {
|
||||
"name": "Spec Kit Figma",
|
||||
"id": "figma",
|
||||
"description": "Agent-agnostic SpecKit extension that grounds spec, plan & task generation in Figma design context — REST + optional MCP, single/mono/multi-repo, macOS/Linux/Windows.",
|
||||
"author": "Fyloss",
|
||||
"version": "1.6.0",
|
||||
"download_url": "https://github.com/Fyloss/spec-kit-figma/archive/refs/tags/v1.6.0.zip",
|
||||
"repository": "https://github.com/Fyloss/spec-kit-figma",
|
||||
"homepage": "https://github.com/Fyloss/spec-kit-figma",
|
||||
"documentation": "https://github.com/Fyloss/spec-kit-figma/blob/main/docs/INSTALL.md",
|
||||
"changelog": "https://github.com/Fyloss/spec-kit-figma/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "integration",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"tools": [
|
||||
{ "name": "git", "required": true },
|
||||
{ "name": "bash", "required": false },
|
||||
{ "name": "curl", "required": false },
|
||||
{ "name": "jq", "required": false },
|
||||
{ "name": "pwsh", "required": false }
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 5,
|
||||
"hooks": 6
|
||||
},
|
||||
"tags": [
|
||||
"figma",
|
||||
"design",
|
||||
"frontend",
|
||||
"ui",
|
||||
"design-system"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-08T00:00:00Z",
|
||||
"updated_at": "2026-07-08T00:00:00Z"
|
||||
},
|
||||
"fix-findings": {
|
||||
"name": "Fix Findings",
|
||||
"id": "fix-findings",
|
||||
@@ -4356,40 +4282,6 @@
|
||||
"created_at": "2026-03-03T00:00:00Z",
|
||||
"updated_at": "2026-04-09T00:00:00Z"
|
||||
},
|
||||
"verify-review-ship": {
|
||||
"name": "Verify Review Ship",
|
||||
"id": "verify-review-ship",
|
||||
"description": "Adds post-implementation verify, review, and ship readiness gates to Spec Kit workflows.",
|
||||
"author": "Carlos Eduardo Gevaerd Araujo",
|
||||
"version": "0.1.0",
|
||||
"download_url": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/archive/refs/tags/v0.1.0.zip",
|
||||
"repository": "https://github.com/cadugevaerd/spec-kit-verify-review-ship",
|
||||
"homepage": "https://github.com/cadugevaerd/spec-kit-verify-review-ship",
|
||||
"documentation": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/blob/main/README.md",
|
||||
"changelog": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-only",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"quality",
|
||||
"review",
|
||||
"shipping",
|
||||
"workflow",
|
||||
"testing"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-10T00:00:00Z",
|
||||
"updated_at": "2026-07-10T00:00:00Z"
|
||||
},
|
||||
"verify-tasks": {
|
||||
"name": "Verify Tasks Extension",
|
||||
"id": "verify-tasks",
|
||||
|
||||
@@ -48,4 +48,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -446,10 +446,7 @@ if ($env:GIT_BRANCH_NAME) {
|
||||
$branchSuffix = Get-BranchName -Description $featureDesc
|
||||
}
|
||||
|
||||
# Warn if -Number and -Timestamp are both specified. Use ContainsKey (not
|
||||
# `-ne 0`) so an explicit `-Number 0` is also detected, matching the bash twin's
|
||||
# `[ -n "$BRANCH_NUMBER" ]` check.
|
||||
if ($Timestamp -and $PSBoundParameters.ContainsKey('Number')) {
|
||||
if ($Timestamp -and $Number -ne 0) {
|
||||
Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
|
||||
$Number = 0
|
||||
}
|
||||
@@ -459,10 +456,7 @@ if ($env:GIT_BRANCH_NAME) {
|
||||
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
|
||||
} else {
|
||||
$branchScopePrefix = Get-BranchScopePrefix -Template $branchTemplate -BranchSuffix $branchSuffix
|
||||
# Auto-detect the next number only when -Number was not supplied; an
|
||||
# explicit value (including 0) is honored, matching the bash twin's
|
||||
# `[ -z "$BRANCH_NUMBER" ]` check.
|
||||
if (-not $PSBoundParameters.ContainsKey('Number')) {
|
||||
if ($Number -eq 0) {
|
||||
if ($DryRun -and $hasGit) {
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch -ScopePrefix $branchScopePrefix
|
||||
} elseif ($DryRun) {
|
||||
|
||||
@@ -48,7 +48,7 @@ cat .specify/extensions/.registry/$ARGUMENTS.json
|
||||
|
||||
### Step 4: Verification Report
|
||||
|
||||
Analyze the standard output of the three steps.
|
||||
Analyze the standard output of the three steps.
|
||||
Generate a terminal-style test output format detailing the results of discovery, installation, and registration. Return this directly to the user.
|
||||
|
||||
Example output format:
|
||||
|
||||
@@ -76,3 +76,5 @@ Areas under discussion or in progress for future development:
|
||||
- **Continued agent expansion** -- seven new agents were added in March alone. The agent-agnostic design means support for emerging tools can be added by anyone. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/)
|
||||
- **Experience simplification** -- the preset system, custom workflows, and growing walkthrough library lower the learning curve, but extension discoverability will need a more robust solution as the catalog grows. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
- **Toward a stable release** -- nine releases in one month reflects pre-1.0 momentum. Reaching 1.0 will require stabilizing the extension and preset APIs and ensuring backward compatibility across the agent and extension surface area. [\[github.com\]](https://github.com/github/spec-kit/blob/main/newsletters/2026-February.md)
|
||||
|
||||
|
||||
|
||||
@@ -158,7 +158,8 @@ presets/
|
||||
├── plan-template.md
|
||||
├── tasks-template.md
|
||||
├── checklist-template.md
|
||||
└── constitution-template.md
|
||||
├── constitution-template.md
|
||||
└── agent-file-template.md
|
||||
```
|
||||
|
||||
## Module Structure
|
||||
|
||||
@@ -44,6 +44,12 @@ provides:
|
||||
description: "Self-test constitution template"
|
||||
replaces: "constitution-template"
|
||||
|
||||
- type: "template"
|
||||
name: "agent-file-template"
|
||||
file: "templates/agent-file-template.md"
|
||||
description: "Self-test agent file template"
|
||||
replaces: "agent-file-template"
|
||||
|
||||
- type: "command"
|
||||
name: "speckit.specify"
|
||||
file: "commands/speckit.specify.md"
|
||||
|
||||
9
presets/self-test/templates/agent-file-template.md
Normal file
9
presets/self-test/templates/agent-file-template.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Agent File (Self-Test Preset)
|
||||
|
||||
<!-- preset:self-test -->
|
||||
|
||||
> This template is provided by the self-test preset.
|
||||
|
||||
## Agent Instructions
|
||||
|
||||
Follow these guidelines when working on this project.
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.12.13"
|
||||
version = "0.12.9"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -83,3 +83,4 @@ extend-select = [
|
||||
"S604", # call-with-shell-equals-true
|
||||
"S605", # start-process-with-a-shell
|
||||
]
|
||||
|
||||
|
||||
@@ -57,13 +57,13 @@ OPTIONS:
|
||||
EXAMPLES:
|
||||
# Check task prerequisites (plan.md required)
|
||||
./check-prerequisites.sh --json
|
||||
|
||||
|
||||
# Check implementation prerequisites (plan.md + tasks.md required)
|
||||
./check-prerequisites.sh --json --require-tasks --include-tasks
|
||||
|
||||
|
||||
# Get feature paths only (no validation)
|
||||
./check-prerequisites.sh --paths-only
|
||||
|
||||
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
@@ -182,13 +182,13 @@ else
|
||||
# Text output
|
||||
echo "FEATURE_DIR:$FEATURE_DIR"
|
||||
echo "AVAILABLE_DOCS:"
|
||||
|
||||
|
||||
# Show status of each potential document
|
||||
check_file "$RESEARCH" "research.md"
|
||||
check_file "$DATA_MODEL" "data-model.md"
|
||||
check_dir "$CONTRACTS_DIR" "contracts/"
|
||||
check_file "$QUICKSTART" "quickstart.md"
|
||||
|
||||
|
||||
if $INCLUDE_TASKS; then
|
||||
check_file "$TASKS" "tasks.md"
|
||||
fi
|
||||
|
||||
@@ -94,7 +94,7 @@ fi
|
||||
get_highest_from_specs() {
|
||||
local specs_dir="$1"
|
||||
local highest=0
|
||||
|
||||
|
||||
if [ -d "$specs_dir" ]; then
|
||||
for dir in "$specs_dir"/*; do
|
||||
[ -d "$dir" ] || continue
|
||||
@@ -109,7 +109,7 @@ get_highest_from_specs() {
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
echo "$highest"
|
||||
}
|
||||
|
||||
@@ -135,19 +135,19 @@ fi
|
||||
# Function to generate branch name with stop word filtering and length filtering
|
||||
generate_branch_name() {
|
||||
local description="$1"
|
||||
|
||||
|
||||
# Common stop words to filter out
|
||||
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
|
||||
|
||||
|
||||
# Convert to lowercase and split into words
|
||||
local clean_name=$(printf '%s' "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
|
||||
|
||||
|
||||
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
|
||||
local meaningful_words=()
|
||||
for word in $clean_name; do
|
||||
# Skip empty words
|
||||
[ -z "$word" ] && continue
|
||||
|
||||
|
||||
# Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms)
|
||||
if ! echo "$word" | grep -qiE "$stop_words"; then
|
||||
if [ ${#word} -ge 3 ]; then
|
||||
@@ -160,12 +160,12 @@ generate_branch_name() {
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
# If we have meaningful words, use first 3-4 of them
|
||||
if [ ${#meaningful_words[@]} -gt 0 ]; then
|
||||
local max_words=3
|
||||
if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
|
||||
|
||||
|
||||
local result=""
|
||||
local count=0
|
||||
for word in "${meaningful_words[@]}"; do
|
||||
@@ -221,15 +221,15 @@ if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
|
||||
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
|
||||
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
|
||||
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
|
||||
|
||||
|
||||
# Truncate suffix at word boundary if possible
|
||||
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
|
||||
# Remove trailing hyphen if truncation created one
|
||||
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
|
||||
|
||||
|
||||
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
|
||||
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
|
||||
|
||||
|
||||
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
|
||||
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
|
||||
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
|
||||
|
||||
@@ -8,17 +8,17 @@ ARGS=()
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--json)
|
||||
JSON_MODE=true
|
||||
--json)
|
||||
JSON_MODE=true
|
||||
;;
|
||||
--help|-h)
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--json]"
|
||||
echo " --json Output results in JSON format"
|
||||
echo " --help Show this help message"
|
||||
exit 0
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
ARGS+=("$arg")
|
||||
*)
|
||||
ARGS+=("$arg")
|
||||
;;
|
||||
esac
|
||||
done
|
||||
@@ -77,7 +77,8 @@ if $JSON_MODE; then
|
||||
fi
|
||||
else
|
||||
echo "FEATURE_SPEC: $FEATURE_SPEC"
|
||||
echo "IMPL_PLAN: $IMPL_PLAN"
|
||||
echo "IMPL_PLAN: $IMPL_PLAN"
|
||||
echo "SPECS_DIR: $FEATURE_DIR"
|
||||
echo "BRANCH: $CURRENT_BRANCH"
|
||||
fi
|
||||
|
||||
|
||||
@@ -42,10 +42,10 @@ OPTIONS:
|
||||
EXAMPLES:
|
||||
# Check task prerequisites (plan.md required)
|
||||
.\check-prerequisites.ps1 -Json
|
||||
|
||||
|
||||
# Check implementation prerequisites (plan.md + tasks.md required)
|
||||
.\check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
|
||||
|
||||
|
||||
# Get feature paths only (no validation)
|
||||
.\check-prerequisites.ps1 -PathsOnly
|
||||
|
||||
@@ -118,35 +118,35 @@ if (Test-Path $paths.RESEARCH) { $docs += 'research.md' }
|
||||
if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' }
|
||||
|
||||
# Check contracts directory (only if it exists and has files)
|
||||
if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
|
||||
$docs += 'contracts/'
|
||||
if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
|
||||
$docs += 'contracts/'
|
||||
}
|
||||
|
||||
if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
|
||||
|
||||
# Include tasks.md if requested and it exists
|
||||
if ($IncludeTasks -and (Test-Path $paths.TASKS)) {
|
||||
$docs += 'tasks.md'
|
||||
if ($IncludeTasks -and (Test-Path $paths.TASKS)) {
|
||||
$docs += 'tasks.md'
|
||||
}
|
||||
|
||||
# Output results
|
||||
if ($Json) {
|
||||
# JSON output
|
||||
[PSCustomObject]@{
|
||||
[PSCustomObject]@{
|
||||
FEATURE_DIR = $paths.FEATURE_DIR
|
||||
AVAILABLE_DOCS = $docs
|
||||
AVAILABLE_DOCS = $docs
|
||||
} | ConvertTo-Json -Compress
|
||||
} else {
|
||||
# Text output
|
||||
Write-Output "FEATURE_DIR:$($paths.FEATURE_DIR)"
|
||||
Write-Output "AVAILABLE_DOCS:"
|
||||
|
||||
|
||||
# Show status of each potential document
|
||||
Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Out-Null
|
||||
Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Out-Null
|
||||
Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Out-Null
|
||||
Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Out-Null
|
||||
|
||||
|
||||
if ($IncludeTasks) {
|
||||
Test-FileExists -Path $paths.TASKS -Description 'tasks.md' | Out-Null
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ function Get-FeaturePathsEnv {
|
||||
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json.")
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
# When no branch context exists (no SPECIFY_FEATURE, feature resolved via
|
||||
# SPECIFY_FEATURE_DIRECTORY or feature.json), fall back to the feature
|
||||
# directory basename so CURRENT_BRANCH is a usable identifier rather than
|
||||
|
||||
@@ -63,7 +63,7 @@ if (Test-Path $paths.IMPL_PLAN -PathType Leaf) {
|
||||
|
||||
# Output results
|
||||
if ($Json) {
|
||||
$result = [PSCustomObject]@{
|
||||
$result = [PSCustomObject]@{
|
||||
FEATURE_SPEC = $paths.FEATURE_SPEC
|
||||
IMPL_PLAN = $paths.IMPL_PLAN
|
||||
SPECS_DIR = $paths.FEATURE_DIR
|
||||
|
||||
@@ -5,4 +5,4 @@
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
}
|
||||
@@ -141,9 +141,8 @@ def _install_shared_infra(
|
||||
|
||||
Copies ``.specify/scripts/<variant>/`` and ``.specify/templates/`` from
|
||||
the bundled core_pack or source checkout, where ``<variant>`` is
|
||||
``bash`` when *script_type* is ``"sh"``, ``python`` when it is ``"py"``,
|
||||
and ``powershell`` when it is ``"ps"``. Tracks all installed files in
|
||||
``speckit.manifest.json``.
|
||||
``bash`` when *script_type* is ``"sh"`` and ``powershell`` when it is
|
||||
``"ps"``. Tracks all installed files in ``speckit.manifest.json``.
|
||||
|
||||
Shared scripts and page templates are processed to resolve
|
||||
``__SPECKIT_COMMAND_<NAME>__`` placeholders using *invoke_separator*
|
||||
|
||||
@@ -213,52 +213,6 @@ class CommandRegistrar:
|
||||
".specify.specify/", ".specify/"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def rewrite_extension_paths(
|
||||
text: str, extension_id: str, extension_dir: Path
|
||||
) -> str:
|
||||
"""Rewrite extension-relative paths to their installed locations.
|
||||
|
||||
Extension command bodies reference bundled files relative to the
|
||||
extension root (e.g. ``agents/control/commander.md``). After install
|
||||
those files live under ``.specify/extensions/<id>/``, so bare
|
||||
references would resolve against the workspace root and never be
|
||||
found (#2101).
|
||||
|
||||
Only directories that actually exist inside *extension_dir* are
|
||||
rewritten, keeping the behaviour conservative and avoiding false
|
||||
positives on prose. ``commands`` (slash-command sources), ``specs``
|
||||
(user project artifacts) and dot-directories are never rewritten.
|
||||
"""
|
||||
if not isinstance(text, str) or not text:
|
||||
return text
|
||||
|
||||
skip = {"commands", ".git", "specs"}
|
||||
try:
|
||||
subdirs = [
|
||||
entry.name
|
||||
for entry in extension_dir.iterdir()
|
||||
if entry.is_dir()
|
||||
and entry.name not in skip
|
||||
and not entry.name.startswith(".")
|
||||
]
|
||||
except OSError:
|
||||
return text
|
||||
|
||||
for subdir in subdirs:
|
||||
# Only rewrite relative references (subdir/... or ./subdir/...);
|
||||
# absolute paths like /subdir/... keep their meaning. Use a
|
||||
# callable replacement: subdir/extension_id come from the
|
||||
# filesystem and could contain backslashes or "\1"-like
|
||||
# sequences, which would corrupt a string replacement template.
|
||||
replacement = f".specify/extensions/{extension_id}/{subdir}/"
|
||||
text = re.sub(
|
||||
r'(^|[\s`"\'(])(?:\./)?' + re.escape(subdir) + "/",
|
||||
lambda m: m.group(1) + replacement,
|
||||
text,
|
||||
)
|
||||
return text
|
||||
|
||||
def render_markdown_command(
|
||||
self, frontmatter: dict, body: str, source_id: str, context_note: str = None
|
||||
) -> str:
|
||||
@@ -685,9 +639,6 @@ class CommandRegistrar:
|
||||
frontmatter[key] = core_frontmatter[key]
|
||||
frontmatter.pop("strategy", None)
|
||||
|
||||
if extension_id:
|
||||
body = self.rewrite_extension_paths(body, extension_id, source_root)
|
||||
|
||||
frontmatter = self._adjust_script_paths(
|
||||
frontmatter, extension_id=extension_id
|
||||
)
|
||||
|
||||
@@ -196,15 +196,7 @@ def find_entries_for_url(
|
||||
url: str, entries: list[AuthConfigEntry]
|
||||
) -> list[AuthConfigEntry]:
|
||||
"""Return entries whose ``hosts`` match the hostname of *url*."""
|
||||
# A malformed authority (e.g. an unterminated IPv6 bracket "https://[::1")
|
||||
# makes urlparse/hostname raise ValueError. Treat that the same as a
|
||||
# host-less URL: no entry can match, so return no matches rather than
|
||||
# leaking a raw ValueError out of the shared HTTP client (build_request /
|
||||
# open_url call this before any URL validation).
|
||||
try:
|
||||
hostname = (urlparse(url).hostname or "").lower()
|
||||
except ValueError:
|
||||
return []
|
||||
hostname = (urlparse(url).hostname or "").lower()
|
||||
if not hostname:
|
||||
return []
|
||||
return [
|
||||
|
||||
@@ -68,18 +68,8 @@ def _validate_remote_url(source_id: str, url: str) -> None:
|
||||
Mirrors ``specify_cli.catalogs`` URL validation to avoid MITM/downgrade
|
||||
issues before any network call.
|
||||
"""
|
||||
# A malformed authority (e.g. an unclosed IPv6 bracket ``https://[::1``)
|
||||
# makes urlparse / hostname access raise ValueError. This function's
|
||||
# contract is to raise BundlerError for a bad URL, so surface that as a
|
||||
# clean error rather than leaking a raw ValueError to the caller.
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
except ValueError:
|
||||
raise BundlerError(
|
||||
f"Catalog '{source_id}' URL is malformed: {url}"
|
||||
) from None
|
||||
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
||||
raise BundlerError(
|
||||
f"Catalog '{source_id}' URL must use HTTPS (got {parsed.scheme}://). "
|
||||
@@ -89,7 +79,7 @@ def _validate_remote_url(source_id: str, url: str) -> None:
|
||||
# "https://:8080" or "https://user@...", so requiring netloc would let
|
||||
# those through even though they carry no host. hostname is None in those
|
||||
# cases. Mirrors the fix in ``specify_cli.catalogs`` (#3210).
|
||||
if not hostname:
|
||||
if not parsed.hostname:
|
||||
raise BundlerError(
|
||||
f"Catalog '{source_id}' URL must be a valid URL with a host: {url}"
|
||||
)
|
||||
|
||||
@@ -71,12 +71,8 @@ class CatalogStackBase:
|
||||
"""Validate that a catalog URL uses HTTPS, except localhost HTTP."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
except ValueError:
|
||||
raise cls._error(f"Catalog URL is malformed: {url}") from None
|
||||
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
||||
raise cls._error(
|
||||
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
|
||||
@@ -85,7 +81,7 @@ class CatalogStackBase:
|
||||
# Check hostname, not netloc: netloc is truthy for host-less URLs like
|
||||
# "https://:8080" or "https://user@", so the host guarantee this error
|
||||
# promises would not actually hold. hostname is None in those cases (#3209).
|
||||
if not hostname:
|
||||
if not parsed.hostname:
|
||||
raise cls._error("Catalog URL must be a valid URL with a host.")
|
||||
|
||||
def _load_catalog_config(self, config_path: Path) -> list[CatalogEntry] | None:
|
||||
|
||||
@@ -746,16 +746,11 @@ def _resolve_manifest_path(path: Path | None) -> Path:
|
||||
def _download_manifest(resolved, *, offline: bool):
|
||||
"""Resolve a bundle's manifest from its catalog ``download_url``.
|
||||
|
||||
Catalog ``download_url``s are HTTPS-only (``http`` allowed for localhost),
|
||||
matching the extensions/presets/workflows catalog systems. Remote URLs are
|
||||
fetched with the shared authenticated, redirect-validated HTTP client, and
|
||||
only when not ``--offline``.
|
||||
|
||||
Local and ``file://`` sources are intentionally not resolved here: to
|
||||
install a bundle from disk, pass the path positionally
|
||||
(``specify bundle install ./path/to/bundle.yml`` — a bundle directory or a
|
||||
``.zip`` artifact also works), which :func:`_local_manifest_source` handles
|
||||
before catalog resolution and which never touches ``download_url``.
|
||||
Local/``file://`` URLs always work offline and may point at a ``.zip``
|
||||
artifact, a bundle directory, or a ``bundle.yml`` (handled by
|
||||
:func:`_local_manifest_source`). Remote ``https://`` URLs are fetched with
|
||||
the shared authenticated, redirect-validated HTTP client, and only when not
|
||||
``--offline``.
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -768,35 +763,26 @@ def _download_manifest(resolved, *, offline: bool):
|
||||
parsed = urlparse(url)
|
||||
scheme = parsed.scheme.lower()
|
||||
|
||||
# ``file://`` URLs and bare filesystem paths (including Windows drive paths
|
||||
# like ``C:\bundle.yml``, which urlparse reads as a single-letter scheme)
|
||||
# are not valid catalog download URLs. Catalog URLs are HTTPS-only across
|
||||
# every catalog system; installing from disk is done by passing the path
|
||||
# positionally, which never reaches URL resolution. Give an actionable
|
||||
# error rather than accepting a scheme the rest of the codebase rejects.
|
||||
# On Windows an absolute path like ``C:\bundle.yml`` parses with a
|
||||
# single-letter ``scheme``; treat it as a local file, not a URL scheme.
|
||||
if scheme in ("", "file") or re.match(r"^[A-Za-z]:[\\/]", url):
|
||||
raise BundlerError(
|
||||
f"Catalog entry '{resolved.entry.id}' has a non-HTTP(S) download_url "
|
||||
f"({url}); catalog download URLs must be HTTPS (http for localhost) — "
|
||||
"a file:// URL, a local filesystem path, or a scheme-less value "
|
||||
"(e.g. 'example.com/bundle.zip') is not accepted. "
|
||||
"To install a bundle from disk, pass the path directly: "
|
||||
"'specify bundle install <path-to-bundle.yml | bundle-dir | .zip>'."
|
||||
)
|
||||
local = Path(parsed.path if scheme == "file" else url)
|
||||
manifest = _local_manifest_source(str(local))
|
||||
if manifest is None:
|
||||
raise BundlerError(f"Bundle manifest not found: {local}")
|
||||
return manifest
|
||||
|
||||
# Validate the scheme/host *before* the offline gate so an invalid or
|
||||
# non-HTTPS download_url reports the real problem in every mode, rather
|
||||
# than a misleading "Network access disabled" under --offline.
|
||||
# (_download_remote_manifest re-checks this, but only once network access
|
||||
# is permitted.) HTTPS-only, http allowed for localhost.
|
||||
_require_https(f"bundle '{resolved.entry.id}'", url)
|
||||
if scheme in ("http", "https"):
|
||||
if offline:
|
||||
raise BundlerError(
|
||||
f"Network access disabled; cannot download bundle '{resolved.entry.id}' "
|
||||
f"from {url}."
|
||||
)
|
||||
return _download_remote_manifest(resolved.entry.id, url)
|
||||
|
||||
if offline:
|
||||
raise BundlerError(
|
||||
f"Network access disabled; cannot download bundle '{resolved.entry.id}' "
|
||||
f"from {url}."
|
||||
)
|
||||
return _download_remote_manifest(resolved.entry.id, url)
|
||||
raise BundlerError(
|
||||
f"Unsupported download_url scheme for bundle '{resolved.entry.id}': {url}"
|
||||
)
|
||||
|
||||
|
||||
def _require_https(label: str, url: str) -> None:
|
||||
|
||||
@@ -1078,11 +1078,6 @@ class ExtensionManager:
|
||||
frontmatter = registrar._adjust_script_paths(
|
||||
frontmatter, extension_id=manifest.id
|
||||
)
|
||||
# Mirror the register_commands() rewrite (#2101): resolve
|
||||
# extension-relative subdir references (agents/, knowledge-base/,
|
||||
# etc.) to their installed .specify/extensions/<id>/ location
|
||||
# before the generic placeholder/path resolution below.
|
||||
body = registrar.rewrite_extension_paths(body, manifest.id, extension_dir)
|
||||
body = registrar.resolve_skill_placeholders(
|
||||
selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id
|
||||
)
|
||||
@@ -2760,32 +2755,18 @@ class ConfigManager:
|
||||
if not key.startswith(prefix):
|
||||
continue
|
||||
|
||||
# Remove prefix and split into parts. Drop empty components from a
|
||||
# malformed name (e.g. ``SPECKIT_<EXT>_`` with no key, or
|
||||
# consecutive underscores ``SPECKIT_X__Y``) so we never create an
|
||||
# entry under an empty key.
|
||||
config_path = [p for p in key[len(prefix) :].lower().split("_") if p]
|
||||
if not config_path:
|
||||
continue
|
||||
# Remove prefix and split into parts
|
||||
config_path = key[len(prefix) :].lower().split("_")
|
||||
|
||||
# Build nested dict. Two env vars can collide on a prefix, e.g.
|
||||
# SPECKIT_X_CONNECTION=a and SPECKIT_X_CONNECTION_URL=b. Guard the
|
||||
# walk so a colliding scalar is replaced by a dict (deeper/more
|
||||
# specific vars win) instead of being indexed into — which raised
|
||||
# TypeError ('str' object does not support item assignment) — and
|
||||
# guard the leaf so a scalar processed after the nested var does
|
||||
# not clobber the nested dict. Order-independent: both insertion
|
||||
# orders yield {'connection': {'url': ...}}. Nested-wins mirrors
|
||||
# _merge_configs' dict-preserving semantics.
|
||||
# Build nested dict
|
||||
current = env_config
|
||||
for part in config_path[:-1]:
|
||||
if not isinstance(current.get(part), dict):
|
||||
if part not in current:
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
|
||||
# Set the final value, unless a nested dict already occupies it.
|
||||
if not isinstance(current.get(config_path[-1]), dict):
|
||||
current[config_path[-1]] = value
|
||||
# Set the final value
|
||||
current[config_path[-1]] = value
|
||||
|
||||
return env_config
|
||||
|
||||
|
||||
@@ -1566,14 +1566,7 @@ def extension_set_priority(
|
||||
raw_priority = metadata.get("priority")
|
||||
# Only skip if the stored value is already a valid int equal to requested priority
|
||||
# This ensures corrupted values (e.g., "high") get repaired even when setting to default (10)
|
||||
# A bool is an int in Python (isinstance(True, int) is True), so exclude it explicitly —
|
||||
# mirroring normalize_priority's bool guard — otherwise a corrupted True/False priority
|
||||
# equals 1/0 here and is never repaired.
|
||||
if (
|
||||
isinstance(raw_priority, int)
|
||||
and not isinstance(raw_priority, bool)
|
||||
and raw_priority == priority
|
||||
):
|
||||
if isinstance(raw_priority, int) and raw_priority == priority:
|
||||
console.print(f"[yellow]Extension '{_escape_markup(str(display_name))}' already has priority {priority}[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
|
||||
|
||||
@@ -62,4 +62,4 @@ class DevinIntegration(SkillsIntegration):
|
||||
default=True,
|
||||
help="Install as agent skills (default for Devin)",
|
||||
),
|
||||
]
|
||||
]
|
||||
@@ -18,13 +18,13 @@ from ..manifest import IntegrationManifest
|
||||
|
||||
def format_forge_command_name(cmd_name: str) -> str:
|
||||
"""Convert command name to Forge-compatible hyphenated format.
|
||||
|
||||
|
||||
Forge requires command names to use hyphens instead of dots for
|
||||
compatibility with ZSH and other shells. This function converts
|
||||
dot-notation command names to hyphenated format.
|
||||
|
||||
|
||||
The function is idempotent: already-formatted names are returned unchanged.
|
||||
|
||||
|
||||
Examples:
|
||||
>>> format_forge_command_name("plan")
|
||||
'speckit-plan'
|
||||
@@ -38,26 +38,26 @@ def format_forge_command_name(cmd_name: str) -> str:
|
||||
'speckit-my-extension-example'
|
||||
>>> format_forge_command_name("speckit.jira.sync-status")
|
||||
'speckit-jira-sync-status'
|
||||
|
||||
|
||||
Args:
|
||||
cmd_name: Command name in dot notation (speckit.foo.bar),
|
||||
cmd_name: Command name in dot notation (speckit.foo.bar),
|
||||
hyphenated format (speckit-foo-bar), or plain name (foo)
|
||||
|
||||
|
||||
Returns:
|
||||
Hyphenated command name with 'speckit-' prefix
|
||||
"""
|
||||
# Already in hyphenated format - return as-is (idempotent)
|
||||
if cmd_name.startswith("speckit-"):
|
||||
return cmd_name
|
||||
|
||||
|
||||
# Strip 'speckit.' prefix if present
|
||||
short_name = cmd_name
|
||||
if short_name.startswith("speckit."):
|
||||
short_name = short_name[len("speckit."):]
|
||||
|
||||
|
||||
# Replace all dots with hyphens
|
||||
short_name = short_name.replace(".", "-")
|
||||
|
||||
|
||||
# Return with 'speckit-' prefix
|
||||
return f"speckit-{short_name}"
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ _KIRO_ARG_FALLBACK = "(the user will provide the argument in this conversation)"
|
||||
|
||||
class KiroCliIntegration(MarkdownIntegration):
|
||||
key = "kiro-cli"
|
||||
multi_install_safe = True
|
||||
config = {
|
||||
"name": "Kiro CLI",
|
||||
"folder": ".kiro/",
|
||||
|
||||
@@ -778,7 +778,6 @@ class PresetManager:
|
||||
matching_cmds, ext_id, ext_dir,
|
||||
self.project_root,
|
||||
context_note=f"\n<!-- Extension: {ext_id} -->\n<!-- Config: .specify/extensions/{ext_id}/ -->\n",
|
||||
extension_id=ext_id,
|
||||
)
|
||||
registered = True
|
||||
except Exception:
|
||||
@@ -1200,8 +1199,6 @@ class PresetManager:
|
||||
"command_name": cmd_name,
|
||||
"source_file": source_file,
|
||||
"source": f"extension:{manifest.id}",
|
||||
"extension_id": manifest.id,
|
||||
"extension_dir": ext_root,
|
||||
}
|
||||
modern_skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name)
|
||||
restore_index.setdefault(modern_skill_name, restore_info)
|
||||
@@ -1466,17 +1463,6 @@ class PresetManager:
|
||||
if extension_restore:
|
||||
content = extension_restore["source_file"].read_text(encoding="utf-8")
|
||||
frontmatter, body = registrar.parse_frontmatter(content)
|
||||
# Mirror the register-time rewrite (#2101): resolve
|
||||
# extension-relative subdir references (agents/,
|
||||
# knowledge-base/, etc.) to their installed location before
|
||||
# the generic placeholder resolution below, otherwise
|
||||
# restoring after a preset override removal would leave
|
||||
# bare, unresolvable paths in the skill body.
|
||||
body = registrar.rewrite_extension_paths(
|
||||
body,
|
||||
extension_restore["extension_id"],
|
||||
extension_restore["extension_dir"],
|
||||
)
|
||||
if isinstance(selected_ai, str):
|
||||
body = registrar.resolve_skill_placeholders(
|
||||
selected_ai, frontmatter, body, self.project_root
|
||||
@@ -3052,8 +3038,6 @@ class PresetResolver:
|
||||
"path": candidate,
|
||||
"source": source,
|
||||
"strategy": "replace",
|
||||
"extension_id": ext_id,
|
||||
"extension_dir": ext_dir,
|
||||
})
|
||||
|
||||
# Priority 4: Core templates (always "replace")
|
||||
@@ -3173,32 +3157,10 @@ class PresetResolver:
|
||||
if not layers:
|
||||
return None
|
||||
|
||||
def _read_layer_content(layer: Dict[str, Any]) -> str:
|
||||
"""Read a layer's raw text, rewriting extension-relative subdir
|
||||
references (agents/, knowledge-base/, etc.) to their installed
|
||||
location when the layer is extension-provided (#2101).
|
||||
|
||||
Extension layers are always inserted with strategy "replace"
|
||||
(see collect_all_layers), so a layer only ever needs this
|
||||
rewrite when it wins outright above or serves as the
|
||||
composition base below — never as a mid-stack composing
|
||||
(append/prepend/wrap) layer.
|
||||
"""
|
||||
text = layer["path"].read_text(encoding="utf-8")
|
||||
extension_id = layer.get("extension_id")
|
||||
extension_dir = layer.get("extension_dir")
|
||||
if extension_id and extension_dir:
|
||||
from ..agents import CommandRegistrar
|
||||
|
||||
text = CommandRegistrar.rewrite_extension_paths(
|
||||
text, extension_id, extension_dir
|
||||
)
|
||||
return text
|
||||
|
||||
# If the top (highest-priority) layer is replace, it wins entirely —
|
||||
# lower layers are irrelevant regardless of their strategies.
|
||||
if layers[0]["strategy"] == "replace":
|
||||
return _read_layer_content(layers[0])
|
||||
return layers[0]["path"].read_text(encoding="utf-8")
|
||||
|
||||
# Composition: build content bottom-up from the effective base.
|
||||
# The base is the nearest replace layer scanning from highest priority
|
||||
@@ -3221,7 +3183,7 @@ class PresetResolver:
|
||||
|
||||
# Convert to reversed_layers index
|
||||
base_reversed_idx = len(layers) - 1 - base_layer_idx
|
||||
content = _read_layer_content(layers[base_layer_idx])
|
||||
content = layers[base_layer_idx]["path"].read_text(encoding="utf-8")
|
||||
# Compose only the layers above the base (higher priority = lower index in layers,
|
||||
# higher index in reversed_layers). Process bottom-up from base+1.
|
||||
start_idx = base_reversed_idx + 1
|
||||
|
||||
@@ -469,14 +469,7 @@ def preset_set_priority(
|
||||
raw_priority = metadata.get("priority")
|
||||
# Only skip if the stored value is already a valid int equal to requested priority
|
||||
# This ensures corrupted values (e.g., "high") get repaired even when setting to default (10)
|
||||
# A bool is an int in Python (isinstance(True, int) is True), so exclude it explicitly —
|
||||
# mirroring normalize_priority's bool guard — otherwise a corrupted True/False priority
|
||||
# equals 1/0 here and is never repaired.
|
||||
if (
|
||||
isinstance(raw_priority, int)
|
||||
and not isinstance(raw_priority, bool)
|
||||
and raw_priority == priority
|
||||
):
|
||||
if isinstance(raw_priority, int) and raw_priority == priority:
|
||||
console.print(f"[yellow]Preset '{preset_id}' already has priority {priority}[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
|
||||
|
||||
@@ -403,7 +403,7 @@ def install_shared_infra(
|
||||
# manifest entries the core no longer ships (stale-script cleanup, #3076).
|
||||
seen_rels: set[str] = set()
|
||||
scripts_scanned = False
|
||||
variant_dir = {"sh": "bash", "py": "python"}.get(script_type, "powershell")
|
||||
variant_dir = "bash" if script_type == "sh" else "powershell"
|
||||
|
||||
def _decide_overwrite(rel: str, dst: Path) -> tuple[bool, str | None]:
|
||||
"""Return (write, bucket) where bucket is 'skip', 'preserved', or None."""
|
||||
@@ -465,10 +465,6 @@ def install_shared_infra(
|
||||
for src_path in variant_src.rglob("*"):
|
||||
if not src_path.is_file():
|
||||
continue
|
||||
# Python bytecode caches are local artifacts, not
|
||||
# workflow scripts — never install them.
|
||||
if "__pycache__" in src_path.parts:
|
||||
continue
|
||||
# Mark scanned only once a real source file is seen. An
|
||||
# empty (or symlink-skipped) variant keeps this False, so
|
||||
# stale-cleanup is skipped — otherwise it would treat every
|
||||
|
||||
@@ -601,15 +601,7 @@ def workflow_add(
|
||||
except (ValueError, yaml.YAMLError) as exc:
|
||||
console.print(f"[red]Error:[/red] Invalid workflow YAML: {exc}")
|
||||
raise typer.Exit(1)
|
||||
# Non-string ids (e.g. unquoted ``id: 123`` or ``id: 0``) fall through
|
||||
# to validate_workflow below, which reports a typed error instead of
|
||||
# crashing on ``.strip()`` here. Only None/empty/whitespace-only ids
|
||||
# are rejected as missing.
|
||||
if (
|
||||
definition.id is None
|
||||
or definition.id == ""
|
||||
or (isinstance(definition.id, str) and not definition.id.strip())
|
||||
):
|
||||
if not definition.id or not definition.id.strip():
|
||||
console.print("[red]Error:[/red] Workflow definition has an empty or missing 'id'")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@@ -76,16 +76,8 @@ class WorkflowRegistry:
|
||||
if self.registry_path.exists():
|
||||
try:
|
||||
with open(self.registry_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
# Validate shape: must be a dict with a dict "workflows" field,
|
||||
# otherwise every method that indexes data["workflows"] crashes.
|
||||
# Mirrors StepRegistry._load.
|
||||
if not isinstance(data, dict):
|
||||
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
|
||||
if not isinstance(data.get("workflows"), dict):
|
||||
data["workflows"] = {}
|
||||
return data
|
||||
except (json.JSONDecodeError, ValueError, OSError, UnicodeError):
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# Corrupted registry file — reset to default
|
||||
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
|
||||
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
|
||||
@@ -446,9 +438,9 @@ class WorkflowCatalog:
|
||||
q = query.lower()
|
||||
searchable = " ".join(
|
||||
[
|
||||
str(wf_data.get("name") or ""),
|
||||
str(wf_data.get("description") or ""),
|
||||
str(wf_data.get("id") or ""),
|
||||
wf_data.get("name", ""),
|
||||
wf_data.get("description", ""),
|
||||
wf_data.get("id", ""),
|
||||
]
|
||||
).lower()
|
||||
if q not in searchable:
|
||||
|
||||
@@ -129,49 +129,26 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
|
||||
errors: list[str] = []
|
||||
|
||||
# -- Schema version ---------------------------------------------------
|
||||
# str() so an unquoted ``schema_version: 1.0`` (YAML float) is accepted —
|
||||
# rejecting it would print "Unsupported schema_version 1.0. Expected '1.0'."
|
||||
if str(definition.schema_version) != "1.0":
|
||||
if definition.schema_version not in ("1.0", "1"):
|
||||
errors.append(
|
||||
f"Unsupported schema_version {definition.schema_version!r}. "
|
||||
f"Expected '1.0'."
|
||||
)
|
||||
|
||||
# -- Top-level fields -------------------------------------------------
|
||||
# YAML parses unquoted scalars like ``id: 123`` or ``version: 1.0`` as
|
||||
# int/float; check types before regex/string operations so authoring
|
||||
# mistakes surface as validation errors instead of tracebacks. Only
|
||||
# ``None``/empty-string count as missing so falsey non-strings
|
||||
# (``id: 0``, ``name: false``) still get the typed error.
|
||||
if definition.id is None or definition.id == "":
|
||||
if not definition.id:
|
||||
errors.append("Workflow is missing 'workflow.id'.")
|
||||
elif not isinstance(definition.id, str):
|
||||
errors.append(
|
||||
f"'workflow.id' must be a string, got "
|
||||
f"{type(definition.id).__name__} ({definition.id!r})."
|
||||
)
|
||||
elif not _ID_PATTERN.match(definition.id):
|
||||
errors.append(
|
||||
f"Workflow ID {definition.id!r} must be lowercase alphanumeric "
|
||||
f"with hyphens."
|
||||
)
|
||||
|
||||
if definition.name is None or definition.name == "":
|
||||
if not definition.name:
|
||||
errors.append("Workflow is missing 'workflow.name'.")
|
||||
elif not isinstance(definition.name, str):
|
||||
errors.append(
|
||||
f"'workflow.name' must be a string, got "
|
||||
f"{type(definition.name).__name__} ({definition.name!r})."
|
||||
)
|
||||
|
||||
if definition.version is None or definition.version == "":
|
||||
if not definition.version:
|
||||
errors.append("Workflow is missing 'workflow.version'.")
|
||||
elif not isinstance(definition.version, str):
|
||||
errors.append(
|
||||
f"'workflow.version' must be a string, got "
|
||||
f"{type(definition.version).__name__} ({definition.version!r}) — "
|
||||
f'quote it in YAML (version: "1.0.0").'
|
||||
)
|
||||
elif not re.match(r"^\d+\.\d+\.\d+$", definition.version):
|
||||
errors.append(
|
||||
f"Workflow version {definition.version!r} is not valid "
|
||||
@@ -279,15 +256,9 @@ def _validate_steps(
|
||||
continue
|
||||
|
||||
step_id = step_config.get("id")
|
||||
if step_id is None or step_id == "":
|
||||
if not step_id:
|
||||
errors.append("Step is missing 'id' field.")
|
||||
continue
|
||||
if not isinstance(step_id, str):
|
||||
errors.append(
|
||||
f"Step ID must be a string, got "
|
||||
f"{type(step_id).__name__} ({step_id!r})."
|
||||
)
|
||||
continue
|
||||
|
||||
if ":" in step_id:
|
||||
errors.append(
|
||||
@@ -982,16 +953,7 @@ class WorkflowEngine:
|
||||
from .expressions import evaluate_condition
|
||||
|
||||
max_iters = step_config.get("max_iterations")
|
||||
# A bool is an int in Python (isinstance(True, int) is True
|
||||
# and True == 1), so a bool max_iterations would slip past
|
||||
# the int check and cap the loop at range(0)==1 iteration
|
||||
# instead of the default. Exclude bools, mirroring the
|
||||
# while/do-while validators and the continue_on_error guard.
|
||||
if (
|
||||
isinstance(max_iters, bool)
|
||||
or not isinstance(max_iters, int)
|
||||
or max_iters < 1
|
||||
):
|
||||
if not isinstance(max_iters, int) or max_iters < 1:
|
||||
max_iters = 10
|
||||
condition = step_config.get("condition", False)
|
||||
for _loop_iter in range(max_iters - 1):
|
||||
|
||||
@@ -464,9 +464,9 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
|
||||
if op == "<=":
|
||||
return _safe_compare(left, right, "<=")
|
||||
if op == " in ":
|
||||
return _safe_membership(left, right, negate=False)
|
||||
return left in right if right is not None else False
|
||||
if op == " not in ":
|
||||
return _safe_membership(left, right, negate=True)
|
||||
return left not in right if right is not None else True
|
||||
|
||||
# Numeric literal
|
||||
try:
|
||||
@@ -511,26 +511,6 @@ def _coerce_number(value: Any) -> Any:
|
||||
return value
|
||||
|
||||
|
||||
def _safe_membership(left: Any, right: Any, *, negate: bool) -> bool:
|
||||
"""Safely evaluate ``left in right`` (or ``not in``) without crashing.
|
||||
|
||||
``left in right`` raises ``TypeError`` whenever the operands don't support
|
||||
membership testing — most commonly a non-iterable right operand (``None``,
|
||||
an int, a bool), but also cases like an unhashable ``left`` against a set.
|
||||
In every such case the membership relation is undefined, so treat it as
|
||||
``False`` (``not in`` as ``True``) rather than leaking the error out of the
|
||||
evaluator and crashing the whole workflow. Mirrors the graceful
|
||||
``TypeError`` handling in ``_safe_compare`` for the ordering operators, and
|
||||
generalizes the previous ``right is not None`` guard to any operand pair
|
||||
that can't be membership-tested.
|
||||
"""
|
||||
try:
|
||||
contained = left in right
|
||||
except TypeError:
|
||||
contained = False
|
||||
return not contained if negate else contained
|
||||
|
||||
|
||||
def _safe_compare(left: Any, right: Any, op: str) -> bool:
|
||||
"""Compare two values for ordering, coercing numeric strings when possible.
|
||||
|
||||
|
||||
@@ -47,8 +47,8 @@ class IfThenStep(StepBase):
|
||||
errors.append(
|
||||
f"If step {config.get('id', '?')!r}: 'then' must be a list of steps."
|
||||
)
|
||||
else_branch = config.get("else")
|
||||
if else_branch is not None and not isinstance(else_branch, list):
|
||||
else_branch = config.get("else", [])
|
||||
if else_branch and not isinstance(else_branch, list):
|
||||
errors.append(
|
||||
f"If step {config.get('id', '?')!r}: 'else' must be a list of steps."
|
||||
)
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
@@ -26,20 +25,7 @@ class ShellStep(StepBase):
|
||||
run_cmd = str(run_cmd)
|
||||
|
||||
cwd = context.project_root or "."
|
||||
# Per-step execution timeout in seconds; defaults to 300 for backward
|
||||
# compatibility. The engine does not auto-validate step config, so
|
||||
# validate here as well — a caller that skips WorkflowEngine.validate()
|
||||
# must fail the step cleanly rather than crash subprocess.run() with a
|
||||
# TypeError (or silently coerce ``timeout: true`` to a 1s duration,
|
||||
# since bool is an int subclass).
|
||||
timeout = config.get("timeout", 300)
|
||||
timeout_error = self._timeout_error(config)
|
||||
if timeout_error is not None:
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=timeout_error,
|
||||
output={"exit_code": -1, "stdout": "", "stderr": "invalid timeout"},
|
||||
)
|
||||
|
||||
# NOTE: shell=True is required to support pipes, redirects, and
|
||||
# multi-command expressions in workflow YAML. Workflow authors
|
||||
# control commands; catalog-installed workflows should be reviewed
|
||||
@@ -51,7 +37,7 @@ class ShellStep(StepBase):
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd,
|
||||
timeout=timeout,
|
||||
timeout=300,
|
||||
)
|
||||
output = {
|
||||
"exit_code": proc.returncode,
|
||||
@@ -88,7 +74,7 @@ class ShellStep(StepBase):
|
||||
except subprocess.TimeoutExpired:
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=f"Shell command timed out after {timeout} seconds.",
|
||||
error="Shell command timed out after 300 seconds.",
|
||||
output={"exit_code": -1, "stdout": "", "stderr": "timeout"},
|
||||
)
|
||||
except OSError as exc:
|
||||
@@ -98,32 +84,6 @@ class ShellStep(StepBase):
|
||||
output={"exit_code": -1, "stdout": "", "stderr": str(exc)},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _timeout_error(config: dict[str, Any]) -> str | None:
|
||||
"""Return an error message if ``config['timeout']`` is invalid, else None.
|
||||
|
||||
Shared by execute() and validate() so both paths reject the same
|
||||
values with the same message. An absent ``timeout`` is valid (the
|
||||
default is used). bool is a subclass of int, but ``timeout: true`` is a
|
||||
config error rather than a duration, so it is rejected explicitly.
|
||||
Non-finite floats (YAML ``.inf``/``.nan``) pass a plain ``> 0`` check
|
||||
but would raise in subprocess.run(), so they are rejected too.
|
||||
"""
|
||||
if "timeout" not in config:
|
||||
return None
|
||||
timeout = config["timeout"]
|
||||
if (
|
||||
isinstance(timeout, bool)
|
||||
or not isinstance(timeout, (int, float))
|
||||
or not math.isfinite(timeout)
|
||||
or timeout <= 0
|
||||
):
|
||||
return (
|
||||
f"Shell step {config.get('id', '?')!r}: 'timeout' must be a "
|
||||
f"positive number of seconds, got {timeout!r}."
|
||||
)
|
||||
return None
|
||||
|
||||
def validate(self, config: dict[str, Any]) -> list[str]:
|
||||
errors = super().validate(config)
|
||||
if "run" not in config:
|
||||
@@ -146,7 +106,4 @@ class ShellStep(StepBase):
|
||||
f"Shell step {config.get('id', '?')!r}: 'output_format' must "
|
||||
f"be 'json' when present, got {output_format!r}."
|
||||
)
|
||||
timeout_error = self._timeout_error(config)
|
||||
if timeout_error is not None:
|
||||
errors.append(timeout_error)
|
||||
return errors
|
||||
|
||||
@@ -26,20 +26,6 @@ class SwitchStep(StepBase):
|
||||
str_value = str(value) if value is not None else ""
|
||||
|
||||
cases = config.get("cases", {})
|
||||
if not isinstance(cases, dict):
|
||||
# The engine does not auto-validate step config, so an unvalidated
|
||||
# run with a non-mapping ``cases`` (a list/scalar authoring mistake)
|
||||
# would otherwise raise AttributeError from ``.items()`` below and
|
||||
# crash the whole run. Fail this step loudly instead, mirroring the
|
||||
# fan-out step's non-list ``items`` handling.
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Switch step {config.get('id', '?')!r}: 'cases' must be a "
|
||||
f"mapping, got {type(cases).__name__}."
|
||||
),
|
||||
output={"matched_case": None, "expression_value": value},
|
||||
)
|
||||
for case_key, case_steps in cases.items():
|
||||
if str(case_key) == str_value:
|
||||
return StepResult(
|
||||
|
||||
@@ -6,16 +6,16 @@
|
||||
|
||||
**Note**: This checklist is generated by the `__SPECKIT_COMMAND_CHECKLIST__` command based on feature context and requirements.
|
||||
|
||||
<!--
|
||||
<!--
|
||||
============================================================================
|
||||
IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.
|
||||
|
||||
|
||||
The __SPECKIT_COMMAND_CHECKLIST__ command MUST replace these with actual items based on:
|
||||
- User's specific checklist request
|
||||
- Feature requirements from spec.md
|
||||
- Technical context from plan.md
|
||||
- Implementation details from tasks.md
|
||||
|
||||
|
||||
DO NOT keep these sample items in the generated checklist file.
|
||||
============================================================================
|
||||
-->
|
||||
|
||||
@@ -3,7 +3,6 @@ description: Perform a non-destructive cross-artifact consistency and quality an
|
||||
scripts:
|
||||
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
|
||||
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
|
||||
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
@@ -3,7 +3,6 @@ description: Generate a custom checklist for the current feature based on user r
|
||||
scripts:
|
||||
sh: scripts/bash/check-prerequisites.sh --json
|
||||
ps: scripts/powershell/check-prerequisites.ps1 -Json
|
||||
py: scripts/python/check_prerequisites.py --json
|
||||
---
|
||||
|
||||
## Checklist Purpose: "Unit Tests for English"
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
---
|
||||
description: Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec.
|
||||
handoffs:
|
||||
handoffs:
|
||||
- label: Build Technical Plan
|
||||
agent: speckit.plan
|
||||
prompt: Create a plan for the spec. I am building with...
|
||||
scripts:
|
||||
sh: scripts/bash/check-prerequisites.sh --json --paths-only
|
||||
ps: scripts/powershell/check-prerequisites.ps1 -Json -PathsOnly
|
||||
py: scripts/python/check_prerequisites.py --json --paths-only
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync.
|
||||
handoffs:
|
||||
handoffs:
|
||||
- label: Build Specification
|
||||
agent: speckit.specify
|
||||
prompt: Implement the feature specification based on the updated constitution. I want to build...
|
||||
@@ -81,7 +81,7 @@ Follow this execution flow:
|
||||
- Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align with updated principles.
|
||||
- Read `.specify/templates/spec-template.md` for scope/requirements alignment—update if constitution adds/removes mandatory sections or constraints.
|
||||
- Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline).
|
||||
- Read each installed Spec Kit command file for your agent (including this one) — named `speckit.*` or `speckit-*` (dot or hyphen depending on the agent), or laid out as `speckit-<name>/SKILL.md` for skills-based integrations, e.g. in `.github/agents/`, `.github/skills/`, `.claude/skills/`, or your agent's equivalent commands directory — to verify no outdated references (CLAUDE-only or other agent-specific names) remain when generic guidance is required.
|
||||
- Read each command file in `.specify/templates/commands/*.md` (including this one) to verify no outdated references (agent-specific names like CLAUDE only) remain when generic guidance is required.
|
||||
- Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed.
|
||||
|
||||
5. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):
|
||||
|
||||
@@ -3,7 +3,6 @@ description: Assess the current codebase against the feature's spec, plan, and t
|
||||
scripts:
|
||||
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
|
||||
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
|
||||
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
@@ -3,7 +3,6 @@ description: Execute the implementation plan by processing and executing all tas
|
||||
scripts:
|
||||
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
|
||||
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
|
||||
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
|
||||
---
|
||||
|
||||
## User Input
|
||||
@@ -43,7 +42,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
@@ -145,7 +144,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
6. Execute implementation following the task plan:
|
||||
- **Phase-by-phase execution**: Complete each phase before moving to the next
|
||||
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
|
||||
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
|
||||
- **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks
|
||||
- **File-based coordination**: Tasks affecting the same files must run sequentially
|
||||
- **Validation checkpoints**: Verify each phase completion before proceeding
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Execute the implementation planning workflow using the plan template to generate design artifacts.
|
||||
handoffs:
|
||||
handoffs:
|
||||
- label: Create Tasks
|
||||
agent: speckit.tasks
|
||||
prompt: Break the plan into tasks
|
||||
@@ -68,6 +68,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- Evaluate gates (ERROR if violations unjustified)
|
||||
- Phase 0: Generate research.md (resolve all NEEDS CLARIFICATION)
|
||||
- Phase 1: Generate data-model.md, contracts/, quickstart.md
|
||||
- Phase 1: Update agent context by running the agent script
|
||||
- Re-evaluate Constitution Check post-design
|
||||
|
||||
## Mandatory Post-Execution Hooks
|
||||
@@ -106,7 +107,7 @@ Check if `.specify/extensions.yml` exists in the project root.
|
||||
|
||||
## Completion Report
|
||||
|
||||
Command ends after Phase 1 design. Report branch, IMPL_PLAN path, and generated artifacts.
|
||||
Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts.
|
||||
|
||||
## Phases
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Create or update the feature specification from a natural language feature description.
|
||||
handoffs:
|
||||
handoffs:
|
||||
- label: Build Technical Plan
|
||||
agent: speckit.plan
|
||||
prompt: Create a plan for the spec. I am building with...
|
||||
@@ -147,20 +147,20 @@ Given that feature description, do this:
|
||||
|
||||
```markdown
|
||||
# Specification Quality Checklist: [FEATURE NAME]
|
||||
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: [DATE]
|
||||
**Feature**: [Link to spec.md]
|
||||
|
||||
|
||||
## Content Quality
|
||||
|
||||
|
||||
- [ ] No implementation details (languages, frameworks, APIs)
|
||||
- [ ] Focused on user value and business needs
|
||||
- [ ] Written for non-technical stakeholders
|
||||
- [ ] All mandatory sections completed
|
||||
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
|
||||
- [ ] No [NEEDS CLARIFICATION] markers remain
|
||||
- [ ] Requirements are testable and unambiguous
|
||||
- [ ] Success criteria are measurable
|
||||
@@ -169,16 +169,16 @@ Given that feature description, do this:
|
||||
- [ ] Edge cases are identified
|
||||
- [ ] Scope is clearly bounded
|
||||
- [ ] Dependencies and assumptions identified
|
||||
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
|
||||
- [ ] All functional requirements have clear acceptance criteria
|
||||
- [ ] User scenarios cover primary flows
|
||||
- [ ] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [ ] No implementation details leak into specification
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
- Items marked incomplete require spec updates before `__SPECKIT_COMMAND_CLARIFY__` or `__SPECKIT_COMMAND_PLAN__`
|
||||
```
|
||||
|
||||
@@ -203,20 +203,20 @@ Given that feature description, do this:
|
||||
|
||||
```markdown
|
||||
## Question [N]: [Topic]
|
||||
|
||||
|
||||
**Context**: [Quote relevant spec section]
|
||||
|
||||
|
||||
**What we need to know**: [Specific question from NEEDS CLARIFICATION marker]
|
||||
|
||||
|
||||
**Suggested Answers**:
|
||||
|
||||
|
||||
| Option | Answer | Implications |
|
||||
|--------|--------|--------------|
|
||||
| A | [First suggested answer] | [What this means for the feature] |
|
||||
| B | [Second suggested answer] | [What this means for the feature] |
|
||||
| C | [Third suggested answer] | [What this means for the feature] |
|
||||
| Custom | Provide your own answer | [Explain how to provide custom input] |
|
||||
|
||||
|
||||
**Your choice**: _[Wait for user response]_
|
||||
```
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts.
|
||||
handoffs:
|
||||
handoffs:
|
||||
- label: Analyze For Consistency
|
||||
agent: speckit.analyze
|
||||
prompt: Run a project analysis for consistency
|
||||
@@ -51,7 +51,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
@@ -161,7 +161,7 @@ Every task MUST strictly follow this format:
|
||||
4. **[Story] label**: REQUIRED for user story phase tasks only
|
||||
- Format: [US1], [US2], [US3], etc. (maps to user stories from spec.md)
|
||||
- Setup phase: NO story label
|
||||
- Foundational phase: NO story label
|
||||
- Foundational phase: NO story label
|
||||
- User Story phases: MUST have story label
|
||||
- Polish phase: NO story label
|
||||
5. **Description**: Clear action with exact file path
|
||||
|
||||
@@ -4,7 +4,6 @@ tools: ['github/github-mcp-server/list_issues', 'github/github-mcp-server/issue_
|
||||
scripts:
|
||||
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
|
||||
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
|
||||
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `__SPECKIT_COMMAND_PLAN__` command; its definition describes the execution workflow.
|
||||
**Note**: This template is filled in by the `__SPECKIT_COMMAND_PLAN__` command. See `.specify/templates/plan-template.md` for the execution workflow.
|
||||
|
||||
## Summary
|
||||
|
||||
|
||||
@@ -11,3 +11,4 @@
|
||||
".specify/scripts/powershell/": true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -175,23 +175,7 @@ def test_build_produces_artifact(project: Path):
|
||||
assert len(artifacts) == 1
|
||||
|
||||
|
||||
def _mock_manifest_download(monkeypatch, source_path: Path) -> None:
|
||||
"""Mock the HTTPS manifest fetch to return a locally-authored manifest.
|
||||
|
||||
Catalog ``download_url``s are HTTPS-only, so ``info`` tests can no longer
|
||||
point one at a local file. Patch ``_download_manifest`` to return the
|
||||
manifest parsed from *source_path* (a bundle.yml or a .zip artifact),
|
||||
exercising ``info``'s expansion without a network call.
|
||||
"""
|
||||
from specify_cli.commands.bundle import _local_manifest_source
|
||||
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.commands.bundle._download_manifest",
|
||||
lambda resolved, *, offline: _local_manifest_source(str(source_path)),
|
||||
)
|
||||
|
||||
|
||||
def test_info_expands_full_component_set(project: Path, monkeypatch):
|
||||
def test_info_expands_full_component_set(project: Path):
|
||||
bundle_dir = project / "src-bundle"
|
||||
bundle_dir.mkdir()
|
||||
(bundle_dir / "bundle.yml").write_text(
|
||||
@@ -199,14 +183,13 @@ def test_info_expands_full_component_set(project: Path, monkeypatch):
|
||||
)
|
||||
catalog = project / "local-catalog.json"
|
||||
entry = catalog_entry_dict(
|
||||
"demo-bundle", download_url="https://example.com/demo-bundle.zip"
|
||||
"demo-bundle", download_url=str(bundle_dir / "bundle.yml")
|
||||
)
|
||||
write_catalog_file(catalog, {"demo-bundle": entry})
|
||||
added = runner.invoke(
|
||||
app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]
|
||||
)
|
||||
assert added.exit_code == 0, added.output
|
||||
_mock_manifest_download(monkeypatch, bundle_dir / "bundle.yml")
|
||||
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
|
||||
assert result.exit_code == 0, result.output
|
||||
@@ -224,7 +207,7 @@ def test_info_expands_full_component_set(project: Path, monkeypatch):
|
||||
assert "Trust" in text.output
|
||||
|
||||
|
||||
def test_info_expands_discovery_only_bundle(project: Path, monkeypatch):
|
||||
def test_info_expands_discovery_only_bundle(project: Path):
|
||||
# Discovery-only bundles must still be fully inspectable via `info`;
|
||||
# only `install` is refused for them.
|
||||
bundle_dir = project / "disc-bundle"
|
||||
@@ -234,7 +217,7 @@ def test_info_expands_discovery_only_bundle(project: Path, monkeypatch):
|
||||
)
|
||||
catalog = project / "disc-catalog.json"
|
||||
entry = catalog_entry_dict(
|
||||
"demo-bundle", download_url="https://example.com/demo-bundle.zip"
|
||||
"demo-bundle", download_url=str(bundle_dir / "bundle.yml")
|
||||
)
|
||||
write_catalog_file(catalog, {"demo-bundle": entry})
|
||||
config = {
|
||||
@@ -247,7 +230,6 @@ def test_info_expands_discovery_only_bundle(project: Path, monkeypatch):
|
||||
(project / ".specify" / "bundle-catalogs.yml").write_text(
|
||||
yaml.safe_dump(config), encoding="utf-8"
|
||||
)
|
||||
_mock_manifest_download(monkeypatch, bundle_dir / "bundle.yml")
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
@@ -255,9 +237,8 @@ def test_info_expands_discovery_only_bundle(project: Path, monkeypatch):
|
||||
assert ("extensions", "ext-a") in components
|
||||
|
||||
|
||||
def test_info_expands_zip_sourced_bundle(project: Path, monkeypatch):
|
||||
# A .zip artifact is extracted to read bundle.yml; info expands it. (The
|
||||
# download itself is HTTPS-only now and mocked here — see contract note.)
|
||||
def test_info_resolves_local_zip_download_url(project: Path):
|
||||
# A local .zip artifact as download_url is extracted to read bundle.yml.
|
||||
bundle_dir = project / "zip-src"
|
||||
bundle_dir.mkdir()
|
||||
(bundle_dir / "bundle.yml").write_text(
|
||||
@@ -268,15 +249,12 @@ def test_info_expands_zip_sourced_bundle(project: Path, monkeypatch):
|
||||
catalog = project / "zip-catalog.json"
|
||||
write_catalog_file(
|
||||
catalog,
|
||||
{"demo-bundle": catalog_entry_dict(
|
||||
"demo-bundle", download_url="https://example.com/demo-bundle.zip"
|
||||
)},
|
||||
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=str(artifact))},
|
||||
)
|
||||
added = runner.invoke(
|
||||
app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]
|
||||
)
|
||||
assert added.exit_code == 0, added.output
|
||||
_mock_manifest_download(monkeypatch, artifact)
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
|
||||
@@ -638,21 +638,6 @@ class TestCreateFeatureBash:
|
||||
assert result.returncode != 0
|
||||
assert "requires updated Spec Kit core scripts" in result.stderr
|
||||
|
||||
def test_explicit_number_zero_is_honored(self, tmp_path: Path):
|
||||
"""An explicit --number 0 is honored (yields 000), not treated as
|
||||
'auto-detect'. Pins the canonical behavior the PowerShell twin must
|
||||
mirror; the empty-string check (`[ -z "$BRANCH_NUMBER" ]`) already
|
||||
distinguishes an unset flag from a supplied 0."""
|
||||
project = _setup_project(tmp_path)
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--dry-run", "--number", "0", "--short-name", "zero", "Zero feature",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "000-zero"
|
||||
assert data["FEATURE_NUM"] == "000"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
|
||||
class TestCreateFeaturePowerShell:
|
||||
@@ -957,23 +942,6 @@ class TestCreateFeaturePowerShell:
|
||||
assert result.returncode != 0
|
||||
assert "requires updated Spec Kit core scripts" in result.stderr
|
||||
|
||||
def test_explicit_number_zero_is_honored(self, tmp_path: Path):
|
||||
"""An explicit -Number 0 is honored (yields 000), matching the bash twin's
|
||||
--number 0. Regression guard: -Number defaults to 0, so a bare `-eq 0`
|
||||
check cannot tell an unset flag from a supplied 0 and would silently
|
||||
auto-detect instead. Uses PSBoundParameters.ContainsKey('Number')."""
|
||||
project = _setup_project(tmp_path)
|
||||
result = _run_pwsh(
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "-DryRun", "-Number", "0", "-ShortName", "zero", "Zero feature",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
json_line = [ln for ln in result.stdout.splitlines() if ln.strip().startswith("{")]
|
||||
assert json_line, f"No JSON in output: {result.stdout}"
|
||||
data = json.loads(json_line[-1])
|
||||
assert data["BRANCH_NAME"] == "000-zero"
|
||||
assert data["FEATURE_NUM"] == "000"
|
||||
|
||||
|
||||
# ── auto-commit.sh Tests ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -25,14 +25,6 @@ BASH = shutil.which("bash")
|
||||
POWERSHELL = (
|
||||
shutil.which("pwsh") or shutil.which("powershell.exe") or shutil.which("powershell")
|
||||
)
|
||||
# On Windows, prefer the built-in Windows PowerShell 5.1 (.NET Framework) when a
|
||||
# test needs to exercise a 5.1-specific code path; fall back to whatever
|
||||
# POWERSHELL resolves to elsewhere.
|
||||
WINDOWS_POWERSHELL = (
|
||||
(shutil.which("powershell.exe") or shutil.which("powershell") or POWERSHELL)
|
||||
if os.name == "nt"
|
||||
else POWERSHELL
|
||||
)
|
||||
|
||||
|
||||
def _write_ext_config(project_root: Path, **overrides: object) -> None:
|
||||
@@ -287,14 +279,12 @@ def shlex_quote(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _run_powershell_agent_context_script(
|
||||
project_root: Path, powershell: str | None = None
|
||||
) -> subprocess.CompletedProcess:
|
||||
def _run_powershell_agent_context_script(project_root: Path) -> subprocess.CompletedProcess:
|
||||
script = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1"
|
||||
env = _bundled_script_env(project_root)
|
||||
return subprocess.run(
|
||||
[
|
||||
powershell or POWERSHELL,
|
||||
POWERSHELL,
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
@@ -422,29 +412,6 @@ class TestBundledUpdaterPathValidation:
|
||||
assert output.count("agent-context: updated CLAUDE.md") == 1
|
||||
assert "agent-context: updated agents.md" not in output
|
||||
|
||||
@requires_bash
|
||||
def test_bash_script_discovers_nested_plan(self, tmp_path):
|
||||
"""Plan discovery recurses into scoped layouts (#3024)."""
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_install_agent_context_config(
|
||||
project,
|
||||
context_file="AGENTS.md",
|
||||
context_files=[],
|
||||
)
|
||||
plan = project / "specs" / "scope" / "001-feature" / "plan.md"
|
||||
plan.parent.mkdir(parents=True)
|
||||
plan.write_text("# Plan\n", encoding="utf-8")
|
||||
|
||||
result = _run_bash_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
text = (project / "AGENTS.md").read_text(encoding="utf-8")
|
||||
# The old one-level glob (specs/*/plan.md) would find nothing here, so no
|
||||
# "at" line would be emitted. Normalize separators before matching: on
|
||||
# MSYS bash the emitted path may be absolute with backslashes.
|
||||
assert "specs/scope/001-feature/plan.md" in text.replace("\\", "/")
|
||||
|
||||
@requires_bash
|
||||
def test_bash_script_falls_back_from_invalid_speckit_python(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
@@ -517,33 +484,6 @@ class TestBundledUpdaterPathValidation:
|
||||
assert output.count("agent-context: updated CLAUDE.md") == 1
|
||||
assert "agent-context: updated agents.md" not in output
|
||||
|
||||
@pytest.mark.skipif(WINDOWS_POWERSHELL is None, reason="PowerShell not available")
|
||||
def test_powershell_script_discovers_nested_plan(self, tmp_path):
|
||||
"""Plan discovery recurses into scoped layouts (#3024).
|
||||
|
||||
The relative-path fix this covers is specific to Windows PowerShell 5.1
|
||||
(.NET Framework), so prefer ``powershell.exe`` over ``pwsh`` here to
|
||||
actually exercise that failure mode on Windows.
|
||||
"""
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_install_agent_context_config(
|
||||
project,
|
||||
context_file="AGENTS.md",
|
||||
context_files=[],
|
||||
)
|
||||
plan = project / "specs" / "scope" / "001-feature" / "plan.md"
|
||||
plan.parent.mkdir(parents=True)
|
||||
plan.write_text("# Plan\n", encoding="utf-8")
|
||||
|
||||
result = _run_powershell_agent_context_script(
|
||||
project, powershell=WINDOWS_POWERSHELL
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
text = (project / "AGENTS.md").read_text(encoding="utf-8")
|
||||
assert "at specs/scope/001-feature/plan.md" in text
|
||||
|
||||
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
|
||||
def test_powershell_script_falls_back_from_invalid_speckit_python(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
@@ -748,62 +688,6 @@ class TestExtensionSelfSeed:
|
||||
_MDC_CONTEXT_FILE = ".cursor/rules/specify-rules.mdc"
|
||||
|
||||
|
||||
class TestPlanDiscovery:
|
||||
"""Mtime fallback must find plans in nested spec layouts (#3024).
|
||||
|
||||
Repos using SPECIFY_FEATURE_DIRECTORY place plans at
|
||||
``specs/<scope>/<feature>/plan.md``; a one-level ``specs/*/plan.md``
|
||||
glob never matches those.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _make_plans(project: Path) -> Path:
|
||||
# Older flat plan plus a newer nested plan: recursive discovery
|
||||
# must pick the nested one by mtime.
|
||||
flat = project / "specs" / "old-feature" / "plan.md"
|
||||
flat.parent.mkdir(parents=True)
|
||||
flat.write_text("flat plan\n", encoding="utf-8")
|
||||
os.utime(flat, (1_000_000_000, 1_000_000_000))
|
||||
nested = project / "specs" / "scope" / "new-feature" / "plan.md"
|
||||
nested.parent.mkdir(parents=True)
|
||||
nested.write_text("nested plan\n", encoding="utf-8")
|
||||
return nested
|
||||
|
||||
@requires_bash
|
||||
def test_bash_script_finds_nested_plan(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_install_agent_context_config(
|
||||
project,
|
||||
context_file="AGENTS.md",
|
||||
context_files=["AGENTS.md"],
|
||||
)
|
||||
self._make_plans(project)
|
||||
|
||||
result = _run_bash_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
content = (project / "AGENTS.md").read_text(encoding="utf-8")
|
||||
assert "specs/scope/new-feature/plan.md" in content
|
||||
|
||||
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
|
||||
def test_powershell_script_finds_nested_plan(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_install_agent_context_config(
|
||||
project,
|
||||
context_file="AGENTS.md",
|
||||
context_files=["AGENTS.md"],
|
||||
)
|
||||
self._make_plans(project)
|
||||
|
||||
result = _run_powershell_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
content = (project / "AGENTS.md").read_text(encoding="utf-8")
|
||||
assert "specs/scope/new-feature/plan.md" in content
|
||||
|
||||
|
||||
class TestMdcFrontmatter:
|
||||
"""Cursor-style ``.mdc`` targets must carry ``alwaysApply: true`` frontmatter
|
||||
so the rule file is auto-loaded; non-``.mdc`` targets must not gain any."""
|
||||
|
||||
@@ -6,7 +6,7 @@ hooks:
|
||||
extension: "test-extension"
|
||||
command: "pre_implement_test"
|
||||
description: "Test before implement hook execution"
|
||||
|
||||
|
||||
after_implement:
|
||||
- id: post_test
|
||||
enabled: true
|
||||
|
||||
@@ -7,7 +7,7 @@ This directory contains a mock project to verify that LLM agents correctly ident
|
||||
1. Open a chat with an LLM (like GitHub Copilot) in this project.
|
||||
2. Ask it to generate tasks for the current directory:
|
||||
> "Please follow `/speckit.tasks` for the `./tests/hooks` directory."
|
||||
3. **Expected Behavior**:
|
||||
3. **Expected Behavior**:
|
||||
- Before doing any generation, the LLM should notice the `AUTOMATIC Pre-Hook` in `.specify/extensions.yml` under `before_tasks`.
|
||||
- It should state it is executing `EXECUTE_COMMAND: pre_tasks_test`.
|
||||
- It should then proceed to read the `.md` docs and produce a `tasks.md`.
|
||||
|
||||
@@ -112,62 +112,3 @@ def test_install_bundled_extension_from_zip_offline(tmp_path: Path):
|
||||
assert not ExtensionManager(project).registry.is_installed("agent-context")
|
||||
finally:
|
||||
os.chdir(previous)
|
||||
|
||||
|
||||
def test_download_manifest_rejects_file_url(tmp_path: Path):
|
||||
"""A catalog ``file://`` download_url is rejected — catalog URLs are
|
||||
HTTPS-only, matching extensions/presets/workflows. Disk installs go through
|
||||
the positional path (see the local-source tests above), not download_url.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from specify_cli.commands.bundle import _download_manifest
|
||||
|
||||
manifest_path = write_manifest(tmp_path / "my bundles")
|
||||
resolved = SimpleNamespace(
|
||||
entry=SimpleNamespace(id="demo-bundle", download_url=manifest_path.as_uri())
|
||||
)
|
||||
|
||||
with pytest.raises(BundlerError, match="bundle install"):
|
||||
_download_manifest(resolved, offline=True)
|
||||
|
||||
|
||||
def test_download_manifest_rejects_bare_path(tmp_path: Path):
|
||||
"""A bare filesystem path download_url is likewise rejected."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from specify_cli.commands.bundle import _download_manifest
|
||||
|
||||
manifest_path = write_manifest(tmp_path / "plain")
|
||||
resolved = SimpleNamespace(
|
||||
entry=SimpleNamespace(id="demo-bundle", download_url=str(manifest_path))
|
||||
)
|
||||
|
||||
with pytest.raises(BundlerError, match="bundle install"):
|
||||
_download_manifest(resolved, offline=True)
|
||||
|
||||
|
||||
def test_local_install_still_resolves_via_positional_path(tmp_path: Path):
|
||||
"""The supported local route — a positional path, not a download_url —
|
||||
still resolves the manifest via _local_manifest_source."""
|
||||
manifest_path = write_manifest(tmp_path / "my bundles")
|
||||
manifest = _local_manifest_source(str(manifest_path))
|
||||
assert manifest is not None
|
||||
assert manifest.bundle.id == "demo-bundle"
|
||||
|
||||
|
||||
def test_download_manifest_rejects_non_https_url_even_offline(tmp_path: Path):
|
||||
"""A non-HTTPS download_url must report the HTTPS problem, not a misleading
|
||||
'Network access disabled', even under --offline (scheme is validated before
|
||||
the offline gate)."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from specify_cli.commands.bundle import _download_manifest
|
||||
|
||||
resolved = SimpleNamespace(
|
||||
entry=SimpleNamespace(
|
||||
id="demo-bundle", download_url="http://example.com/bundle.zip"
|
||||
)
|
||||
)
|
||||
with pytest.raises(BundlerError, match="HTTPS"):
|
||||
_download_manifest(resolved, offline=True)
|
||||
|
||||
@@ -84,20 +84,6 @@ class TestCatalogURLValidation:
|
||||
with pytest.raises(IntegrationCatalogError, match="valid URL"):
|
||||
IntegrationCatalog._validate_catalog_url(url)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"https://[::1", # unclosed ipv6 bracket
|
||||
"https://[not-an-ip]/c.json", # bracketed non-ip host
|
||||
],
|
||||
)
|
||||
def test_malformed_url_rejected_cleanly(self, url):
|
||||
# A malformed authority makes urlparse/hostname raise ValueError. The
|
||||
# validator must turn that into its normal catalog error, not leak a
|
||||
# raw ValueError to the caller.
|
||||
with pytest.raises(IntegrationCatalogError, match="malformed"):
|
||||
IntegrationCatalog._validate_catalog_url(url)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IntegrationCatalog — active catalogs
|
||||
|
||||
@@ -231,3 +231,4 @@ class TestCursorAgentCliDispatch:
|
||||
|
||||
argv = mock_run.call_args[0][0]
|
||||
assert argv[0] == "cursor-agent"
|
||||
|
||||
|
||||
@@ -71,4 +71,4 @@ class TestDevinInitFlow:
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"init --integration devin failed: {result.output}"
|
||||
assert (target / ".devin" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
assert (target / ".devin" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
@@ -288,13 +288,13 @@ class TestForgeCommandRegistrar:
|
||||
def test_registrar_formats_extension_command_names_for_forge(self, tmp_path):
|
||||
"""Verify CommandRegistrar converts dot notation to hyphens for Forge."""
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
|
||||
# Create a mock extension command file
|
||||
ext_dir = tmp_path / "extension"
|
||||
ext_dir.mkdir()
|
||||
cmd_dir = ext_dir / "commands"
|
||||
cmd_dir.mkdir()
|
||||
|
||||
|
||||
# Create a test command with dot notation name
|
||||
cmd_file = cmd_dir / "example.md"
|
||||
cmd_file.write_text(
|
||||
@@ -304,7 +304,7 @@ class TestForgeCommandRegistrar:
|
||||
"Test content with $ARGUMENTS\n",
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
# Register with Forge
|
||||
registrar = CommandRegistrar()
|
||||
commands = [
|
||||
@@ -313,7 +313,7 @@ class TestForgeCommandRegistrar:
|
||||
"file": "commands/example.md"
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
registered = registrar.register_commands(
|
||||
"forge",
|
||||
commands,
|
||||
@@ -321,14 +321,14 @@ class TestForgeCommandRegistrar:
|
||||
ext_dir,
|
||||
tmp_path
|
||||
)
|
||||
|
||||
|
||||
# Verify registration succeeded
|
||||
assert "speckit.my-extension.example" in registered
|
||||
|
||||
|
||||
# Check the generated file has hyphenated name in frontmatter
|
||||
forge_cmd = tmp_path / ".forge" / "commands" / "speckit-my-extension-example.md"
|
||||
assert forge_cmd.exists()
|
||||
|
||||
|
||||
content = forge_cmd.read_text(encoding="utf-8")
|
||||
# Parse frontmatter to validate name field precisely
|
||||
frontmatter, _ = registrar.parse_frontmatter(content)
|
||||
@@ -339,13 +339,13 @@ class TestForgeCommandRegistrar:
|
||||
def test_registrar_formats_alias_names_for_forge(self, tmp_path):
|
||||
"""Verify CommandRegistrar converts alias names to hyphens for Forge."""
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
|
||||
# Create a mock extension command file
|
||||
ext_dir = tmp_path / "extension"
|
||||
ext_dir.mkdir()
|
||||
cmd_dir = ext_dir / "commands"
|
||||
cmd_dir.mkdir()
|
||||
|
||||
|
||||
cmd_file = cmd_dir / "example.md"
|
||||
cmd_file.write_text(
|
||||
"---\n"
|
||||
@@ -354,7 +354,7 @@ class TestForgeCommandRegistrar:
|
||||
"Test content\n",
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
# Register with Forge including an alias
|
||||
registrar = CommandRegistrar()
|
||||
commands = [
|
||||
@@ -364,7 +364,7 @@ class TestForgeCommandRegistrar:
|
||||
"aliases": ["speckit.my-extension.ex"]
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
registrar.register_commands(
|
||||
"forge",
|
||||
commands,
|
||||
@@ -372,11 +372,11 @@ class TestForgeCommandRegistrar:
|
||||
ext_dir,
|
||||
tmp_path
|
||||
)
|
||||
|
||||
|
||||
# Check the alias file has hyphenated name in frontmatter
|
||||
alias_file = tmp_path / ".forge" / "commands" / "speckit-my-extension-ex.md"
|
||||
assert alias_file.exists()
|
||||
|
||||
|
||||
content = alias_file.read_text(encoding="utf-8")
|
||||
# Parse frontmatter to validate alias name field precisely
|
||||
frontmatter, _ = registrar.parse_frontmatter(content)
|
||||
@@ -387,13 +387,13 @@ class TestForgeCommandRegistrar:
|
||||
def test_registrar_does_not_affect_other_agents(self, tmp_path):
|
||||
"""Verify format_name callback is Forge-specific and doesn't affect other agents."""
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
|
||||
# Create a mock extension command file
|
||||
ext_dir = tmp_path / "extension"
|
||||
ext_dir.mkdir()
|
||||
cmd_dir = ext_dir / "commands"
|
||||
cmd_dir.mkdir()
|
||||
|
||||
|
||||
cmd_file = cmd_dir / "example.md"
|
||||
cmd_file.write_text(
|
||||
"---\n"
|
||||
@@ -402,7 +402,7 @@ class TestForgeCommandRegistrar:
|
||||
"Test content with $ARGUMENTS\n",
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
# Register with Kilo Code (standard markdown agent without inject_name)
|
||||
registrar = CommandRegistrar()
|
||||
commands = [
|
||||
@@ -411,7 +411,7 @@ class TestForgeCommandRegistrar:
|
||||
"file": "commands/example.md"
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
registrar.register_commands(
|
||||
"kilocode",
|
||||
commands,
|
||||
@@ -419,12 +419,12 @@ class TestForgeCommandRegistrar:
|
||||
ext_dir,
|
||||
tmp_path
|
||||
)
|
||||
|
||||
|
||||
# Kilo Code uses standard markdown format without name injection.
|
||||
# The format_name callback should not be invoked for non-Forge agents.
|
||||
kilocode_cmd = tmp_path / ".kilocode" / "workflows" / "speckit.my-extension.example.md"
|
||||
assert kilocode_cmd.exists()
|
||||
|
||||
|
||||
content = kilocode_cmd.read_text(encoding="utf-8")
|
||||
# Kilo Code should NOT have a name field injected
|
||||
assert "name:" not in content, (
|
||||
|
||||
@@ -42,9 +42,6 @@ class TestKiroCliIntegration(MarkdownIntegrationTests):
|
||||
COMMANDS_SUBDIR = "prompts"
|
||||
REGISTRAR_DIR = ".kiro/prompts"
|
||||
|
||||
def test_declares_multi_install_safe(self):
|
||||
assert get_integration(self.KEY).multi_install_safe is True
|
||||
|
||||
def test_registrar_config(self):
|
||||
"""Override base assertion: kiro-cli uses a prose fallback for args
|
||||
because Kiro CLI file-based prompts do not natively substitute
|
||||
|
||||
@@ -34,4 +34,4 @@ class TestVibeUserInvocable:
|
||||
parsed = yaml.safe_load(parts[1])
|
||||
assert parsed.get("user-invocable") is True, (
|
||||
f"{f.parent.name}/SKILL.md is missing user-invocable: true in frontmatter"
|
||||
)
|
||||
)
|
||||
@@ -315,20 +315,6 @@ class TestFindEntriesForUrl:
|
||||
def test_empty_url_returns_empty(self):
|
||||
assert find_entries_for_url("", [_github_entry()]) == []
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"https://[::1", # unterminated ipv6 bracket
|
||||
"https://[not-an-ip]/file", # bracketed non-ip host
|
||||
],
|
||||
)
|
||||
def test_malformed_url_returns_empty(self, url):
|
||||
# A malformed authority makes urlparse/hostname raise ValueError.
|
||||
# Since no entry can match such a URL, this must return no matches
|
||||
# (like a host-less URL) rather than leaking a raw ValueError out of
|
||||
# the shared HTTP client.
|
||||
assert find_entries_for_url(url, [_github_entry()]) == []
|
||||
|
||||
def test_empty_entries_returns_empty(self):
|
||||
assert find_entries_for_url("https://github.com/org/repo", []) == []
|
||||
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
"""Command templates with a py: script line must render for --script py.
|
||||
|
||||
Covers #3283: ``py:`` lines in the ``scripts:`` frontmatter of
|
||||
``templates/commands/*.md`` reference Python scripts that exist in the repo,
|
||||
and ``process_template`` turns them into a valid Python invocation
|
||||
(interpreter-prefixed, path rewritten to the ``.specify`` tree).
|
||||
|
||||
``plan.md`` and ``tasks.md`` gain their ``py:`` lines together with
|
||||
``setup_plan.py``/``setup_tasks.py`` in the core-scripts port (#3280); the
|
||||
existence check below enforces that ordering.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.integrations.base import IntegrationBase
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
TEMPLATES_DIR = REPO_ROOT / "templates" / "commands"
|
||||
|
||||
_PY_LINE = re.compile(r"^\s*py: (scripts/python/\S+\.py)", re.MULTILINE)
|
||||
|
||||
|
||||
def _py_script(name: str) -> str | None:
|
||||
match = _PY_LINE.search((TEMPLATES_DIR / name).read_text(encoding="utf-8"))
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
PY_TEMPLATES = sorted(
|
||||
p.name for p in TEMPLATES_DIR.glob("*.md") if _py_script(p.name)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _pin_interpreter(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which",
|
||||
lambda name: "/usr/bin/python3" if name == "python3" else None,
|
||||
)
|
||||
# On Windows, ``resolve_python_interpreter`` guards the ``which`` result
|
||||
# with a real ``_interpreter_runs`` subprocess probe (#3304). The mocked
|
||||
# ``/usr/bin/python3`` path does not exist on a Windows runner, so the
|
||||
# probe would fail and the resolver would fall back to ``sys.executable``
|
||||
# (a ``...python.exe`` path), breaking the ``python3``-anchored assertion.
|
||||
# Pin the probe to True so the interpreter token stays ``python3`` on all
|
||||
# platforms.
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.IntegrationBase._interpreter_runs",
|
||||
staticmethod(lambda path: True),
|
||||
)
|
||||
|
||||
|
||||
def test_py_templates_discovered():
|
||||
# Guard: the glob must find the known py-scripted templates, otherwise
|
||||
# the parametrized tests below would silently pass on an empty set.
|
||||
assert "implement.md" in PY_TEMPLATES
|
||||
assert "clarify.md" in PY_TEMPLATES
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", PY_TEMPLATES)
|
||||
def test_referenced_python_script_exists(name: str):
|
||||
# A py: line must never point at a script the repo does not ship —
|
||||
# rendering would produce a broken invocation at runtime.
|
||||
script = _py_script(name)
|
||||
assert (REPO_ROOT / script).is_file(), f"{name} references missing {script}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", PY_TEMPLATES)
|
||||
def test_template_renders_python_invocation(name: str):
|
||||
content = (TEMPLATES_DIR / name).read_text(encoding="utf-8")
|
||||
result = IntegrationBase.process_template(content, "agent", "py")
|
||||
assert "{SCRIPT}" not in result
|
||||
assert re.search(
|
||||
r"python3 \.specify/scripts/python/\w+\.py(?: --[\w-]+)*", result
|
||||
), f"{name} did not render a Python invocation"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", PY_TEMPLATES)
|
||||
def test_sh_rendering_unchanged(name: str):
|
||||
# Negative: adding py: lines must not leak into sh rendering.
|
||||
content = (TEMPLATES_DIR / name).read_text(encoding="utf-8")
|
||||
result = IntegrationBase.process_template(content, "agent", "sh")
|
||||
assert "{SCRIPT}" not in result
|
||||
assert "scripts/python" not in result
|
||||
|
||||
|
||||
def test_install_shared_infra_copies_python_scripts(tmp_path):
|
||||
# --script py must install scripts/python/ into .specify/scripts/python/
|
||||
# so the rendered invocations point at files that exist.
|
||||
from rich.console import Console
|
||||
|
||||
from specify_cli.shared_infra import install_shared_infra
|
||||
|
||||
install_shared_infra(
|
||||
tmp_path,
|
||||
"py",
|
||||
version="0.0.0",
|
||||
core_pack=None,
|
||||
repo_root=REPO_ROOT,
|
||||
console=Console(quiet=True),
|
||||
force=False,
|
||||
)
|
||||
dest = tmp_path / ".specify" / "scripts" / "python"
|
||||
assert (dest / "check_prerequisites.py").is_file()
|
||||
assert not (tmp_path / ".specify" / "scripts" / "powershell").exists()
|
||||
@@ -17,7 +17,7 @@ class TestExtensionRegistration:
|
||||
"""Standard registration: Adding an extension should add it to the list."""
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_extension("test-ext")
|
||||
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "installed" in config
|
||||
assert config["installed"] == ["test-ext"]
|
||||
@@ -28,7 +28,7 @@ class TestExtensionRegistration:
|
||||
executor.register_extension("zebra-ext")
|
||||
executor.register_extension("apple-ext")
|
||||
executor.register_extension("middle-ext")
|
||||
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert config["installed"] == ["apple-ext", "middle-ext", "zebra-ext"]
|
||||
|
||||
@@ -37,7 +37,7 @@ class TestExtensionRegistration:
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_extension("test-ext")
|
||||
executor.register_extension("test-ext")
|
||||
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert config["installed"] == ["test-ext"]
|
||||
assert len(config["installed"]) == 1
|
||||
@@ -47,9 +47,9 @@ class TestExtensionRegistration:
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_extension("ext-1")
|
||||
executor.register_extension("ext-2")
|
||||
|
||||
|
||||
executor.unregister_extension("ext-1")
|
||||
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert config["installed"] == ["ext-2"]
|
||||
|
||||
@@ -57,10 +57,10 @@ class TestExtensionRegistration:
|
||||
"""Safe Removal: Unregistering a non-existent extension should do nothing."""
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_extension("ext-1")
|
||||
|
||||
|
||||
# Should not raise or change the list
|
||||
executor.unregister_extension("ext-nonexistent")
|
||||
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert config["installed"] == ["ext-1"]
|
||||
|
||||
@@ -87,27 +87,27 @@ class TestExtensionRegistration:
|
||||
manifest_path = tmp_path / "extension.yml"
|
||||
with open(manifest_path, "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
executor = HookExecutor(project_dir)
|
||||
|
||||
|
||||
# This should call register_extension internally
|
||||
executor.register_hooks(manifest)
|
||||
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "hook-ext" in config["installed"]
|
||||
|
||||
def test_missing_installed_key_initialization(self, project_dir):
|
||||
"""Graceful Initialization: If 'installed' key is missing, it should be created."""
|
||||
executor = HookExecutor(project_dir)
|
||||
|
||||
|
||||
# Manually create a config without 'installed'
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
config_path.write_text(yaml.dump({"settings": {"auto_execute_hooks": True}}))
|
||||
|
||||
|
||||
# This should detect the missing key and initialize it
|
||||
executor.register_extension("new-ext")
|
||||
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "installed" in config
|
||||
assert config["installed"] == ["new-ext"]
|
||||
@@ -135,20 +135,20 @@ class TestExtensionRegistration:
|
||||
manifest_path = tmp_path / "extension.yml"
|
||||
with open(manifest_path, "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
executor = HookExecutor(project_dir)
|
||||
|
||||
|
||||
# Register hooks first
|
||||
executor.register_hooks(manifest)
|
||||
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "hook-ext" in config["installed"]
|
||||
assert "after_tasks" in config["hooks"]
|
||||
|
||||
|
||||
# Now unregister hooks
|
||||
executor.unregister_hooks("hook-ext")
|
||||
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "hook-ext" not in config["installed"]
|
||||
# unregister_hooks() removes the empty hook array entirely, so the key is absent
|
||||
@@ -157,16 +157,16 @@ class TestExtensionRegistration:
|
||||
def test_unregister_hooks_no_hooks_key(self, project_dir):
|
||||
"""Resilience: unregister_hooks should work even if config has no 'hooks' key."""
|
||||
executor = HookExecutor(project_dir)
|
||||
|
||||
|
||||
# Register extension without hooks
|
||||
executor.register_extension("ext-no-hooks")
|
||||
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "ext-no-hooks" in config["installed"]
|
||||
|
||||
|
||||
# Unregister should not crash even if no hooks key exists
|
||||
executor.unregister_hooks("ext-no-hooks")
|
||||
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "ext-no-hooks" not in config["installed"]
|
||||
|
||||
@@ -175,12 +175,12 @@ class TestExtensionRegistration:
|
||||
# Create a corrupted config (root is a list)
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
config_path.write_text(yaml.dump(["corrupted", "list"]))
|
||||
|
||||
|
||||
executor = HookExecutor(project_dir)
|
||||
|
||||
|
||||
# Should not raise even with corrupted config
|
||||
executor.unregister_hooks("non-existent")
|
||||
|
||||
|
||||
# Config should remain as-is or be handled gracefully
|
||||
config = executor.get_project_config()
|
||||
# If it's corrupted, it's returned as-is or handled by defensive logic
|
||||
@@ -223,30 +223,30 @@ class TestExtensionRegistration:
|
||||
"after_tasks": {"command": "speckit.ext-2.run"}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
manifest_path_1 = tmp_path / "extension1.yml"
|
||||
manifest_path_2 = tmp_path / "extension2.yml"
|
||||
with open(manifest_path_1, "w") as f:
|
||||
yaml.dump(manifest_data_1, f)
|
||||
with open(manifest_path_2, "w") as f:
|
||||
yaml.dump(manifest_data_2, f)
|
||||
|
||||
|
||||
manifest1 = ExtensionManifest(manifest_path_1)
|
||||
manifest2 = ExtensionManifest(manifest_path_2)
|
||||
executor = HookExecutor(project_dir)
|
||||
|
||||
|
||||
# Register both extensions
|
||||
executor.register_hooks(manifest1)
|
||||
executor.register_hooks(manifest2)
|
||||
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "ext-1" in config["installed"]
|
||||
assert "ext-2" in config["installed"]
|
||||
assert len(config["hooks"]["after_tasks"]) == 2
|
||||
|
||||
|
||||
# Unregister first extension
|
||||
executor.unregister_hooks("ext-1")
|
||||
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "ext-1" not in config["installed"]
|
||||
assert "ext-2" in config["installed"]
|
||||
@@ -346,9 +346,9 @@ class TestExtensionRegistration:
|
||||
manifest_path = tmp_path / "extension.yml"
|
||||
with open(manifest_path, "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
|
||||
|
||||
# Should not raise TypeError when trying to append to None
|
||||
executor.register_hooks(manifest)
|
||||
|
||||
@@ -378,16 +378,16 @@ class TestExtensionRegistration:
|
||||
"""Review Feedback: register_extension should support and preserve dict entries."""
|
||||
executor = HookExecutor(project_dir)
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
|
||||
|
||||
# Setup config with a pinned extension (dict)
|
||||
pinned_ext = {"id": "pinned-ext", "version": "1.0.0"}
|
||||
config_path.write_text(yaml.dump({
|
||||
"installed": [pinned_ext, "string-ext"]
|
||||
}))
|
||||
|
||||
|
||||
# Register a new extension
|
||||
executor.register_extension("new-ext")
|
||||
|
||||
|
||||
config = executor.get_project_config()
|
||||
# Should contain all three, sorted by id: new-ext, pinned-ext, string-ext
|
||||
assert config["installed"] == ["new-ext", pinned_ext, "string-ext"]
|
||||
@@ -396,15 +396,15 @@ class TestExtensionRegistration:
|
||||
"""Review Feedback: unregister_extension should support removing matching dict entries."""
|
||||
executor = HookExecutor(project_dir)
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
|
||||
|
||||
pinned_ext = {"id": "to-remove", "version": "1.0.0"}
|
||||
config_path.write_text(yaml.dump({
|
||||
"installed": [pinned_ext, "other-ext"]
|
||||
}))
|
||||
|
||||
|
||||
# Unregister by ID
|
||||
executor.unregister_extension("to-remove")
|
||||
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert config["installed"] == ["other-ext"]
|
||||
|
||||
@@ -412,14 +412,14 @@ class TestExtensionRegistration:
|
||||
"""Hardening: unregister_extension should handle non-list installed key."""
|
||||
executor = HookExecutor(project_dir)
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
|
||||
|
||||
config_path.write_text(yaml.dump({
|
||||
"installed": "not-a-list"
|
||||
}))
|
||||
|
||||
|
||||
# Should not crash and should normalize to []
|
||||
executor.unregister_extension("any-ext")
|
||||
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert config["installed"] == []
|
||||
def test_register_hooks_mixed_type_hook_list(self, project_dir, tmp_path):
|
||||
@@ -458,7 +458,7 @@ class TestExtensionRegistration:
|
||||
|
||||
config = executor.get_project_config()
|
||||
hooks = config["hooks"]["after_tasks"]
|
||||
|
||||
|
||||
# Should have 2 valid dict hooks, and 0 non-dict items
|
||||
assert len(hooks) == 2
|
||||
assert all(isinstance(h, dict) for h in hooks)
|
||||
@@ -469,12 +469,12 @@ class TestExtensionRegistration:
|
||||
"""Hardening: unregister_extension should handle scalar root config."""
|
||||
executor = HookExecutor(project_dir)
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
|
||||
|
||||
config_path.write_text(yaml.dump(123))
|
||||
|
||||
|
||||
# Should not crash and should normalize to {}
|
||||
executor.unregister_extension("any-ext")
|
||||
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert isinstance(config, dict)
|
||||
assert config["installed"] == []
|
||||
@@ -483,15 +483,15 @@ class TestExtensionRegistration:
|
||||
"""Regression: unregister_hooks() must handle scalar hook event values."""
|
||||
executor = HookExecutor(project_dir)
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
|
||||
|
||||
config_path.write_text(yaml.dump({
|
||||
"installed": ["some-ext"],
|
||||
"hooks": {"after_tasks": 123}
|
||||
}))
|
||||
|
||||
|
||||
# Should not raise TypeError when iterating
|
||||
executor.unregister_hooks("some-ext")
|
||||
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "some-ext" not in config["installed"]
|
||||
assert "after_tasks" not in config["hooks"]
|
||||
|
||||
@@ -911,65 +911,6 @@ class TestExtensionSkillRegistration:
|
||||
assert ".specify/scripts/bash/resolve-skill.sh" not in content
|
||||
assert ".specify/scripts/bash/ensure-skills.sh" not in content
|
||||
|
||||
def test_skill_registration_rewrites_extension_subdir_paths(self, project_dir, temp_dir):
|
||||
"""Auto-registered skills should resolve extension-relative subdir
|
||||
references (agents/, knowledge-base/) to their installed location,
|
||||
matching the rewrite already applied by register_commands() (#2101)."""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="claude")
|
||||
|
||||
ext_dir = temp_dir / "path-ext"
|
||||
ext_dir.mkdir()
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "path-ext",
|
||||
"name": "Path Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.path-ext.run",
|
||||
"file": "commands/run.md",
|
||||
"description": "Run command",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.safe_dump(manifest_data, f)
|
||||
|
||||
(ext_dir / "commands").mkdir()
|
||||
(ext_dir / "agents" / "control").mkdir(parents=True)
|
||||
(ext_dir / "agents" / "control" / "commander.md").write_text("# Commander\n")
|
||||
(ext_dir / "knowledge-base").mkdir()
|
||||
(ext_dir / "knowledge-base" / "agent-scores.yaml").write_text("scores: {}\n")
|
||||
(ext_dir / "templates").mkdir()
|
||||
(ext_dir / "templates" / "kill-report.md").write_text("# Kill Report\n")
|
||||
|
||||
(ext_dir / "commands" / "run.md").write_text(
|
||||
"---\n"
|
||||
"description: Run command\n"
|
||||
"---\n\n"
|
||||
"Read agents/control/commander.md and knowledge-base/agent-scores.yaml.\n"
|
||||
"Use templates/kill-report.md as the report template.\n"
|
||||
)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
content = (skills_dir / "speckit-path-ext-run" / "SKILL.md").read_text()
|
||||
assert ".specify/extensions/path-ext/agents/control/commander.md" in content
|
||||
assert ".specify/extensions/path-ext/knowledge-base/agent-scores.yaml" in content
|
||||
# extension's own templates/ dir must resolve under the extension,
|
||||
# not the project-level .specify/templates/
|
||||
assert ".specify/extensions/path-ext/templates/kill-report.md" in content
|
||||
assert "Read agents/control" not in content
|
||||
assert "and knowledge-base/" not in content
|
||||
|
||||
def test_missing_command_file_skipped(self, skills_project, temp_dir):
|
||||
"""Commands with missing source files should be skipped gracefully."""
|
||||
project_dir, skills_dir = skills_project
|
||||
|
||||
@@ -20,28 +20,28 @@ def test_extension_update_corrupted_config_root(project_dir, monkeypatch):
|
||||
"""Regression: extension update must handle corrupted extensions.yml (root is scalar)."""
|
||||
# chdir into project_dir so _require_specify_project() succeeds
|
||||
monkeypatch.chdir(project_dir)
|
||||
|
||||
|
||||
# Corrupt extensions.yml
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
config_path.write_text(yaml.dump(123))
|
||||
|
||||
|
||||
# Mock ExtensionManager to return an installed extension for resolution
|
||||
|
||||
|
||||
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}])
|
||||
monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True})
|
||||
monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"})
|
||||
|
||||
|
||||
# Mock download_extension to avoid network calls; use tmp_path so the test is hermetic
|
||||
# and returns a Path so zip_path.exists() / zip_path.unlink() work without AttributeError
|
||||
mock_zip = project_dir / "mock.zip"
|
||||
monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda self, ext_id: mock_zip)
|
||||
|
||||
|
||||
# Mock confirmation to true
|
||||
monkeypatch.setattr("typer.confirm", lambda _: True)
|
||||
|
||||
|
||||
# Run update
|
||||
result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir})
|
||||
|
||||
|
||||
# extension_update() catches exceptions internally and exits with code 1 on failure.
|
||||
assert result.exit_code == 1
|
||||
assert "AttributeError" not in result.output
|
||||
@@ -50,13 +50,13 @@ def test_extension_update_corrupted_config_root(project_dir, monkeypatch):
|
||||
def test_extension_update_corrupted_hooks_value(project_dir, monkeypatch):
|
||||
"""Regression: extension update must handle non-dict 'hooks' in extensions.yml."""
|
||||
monkeypatch.chdir(project_dir)
|
||||
|
||||
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
config_path.write_text(yaml.dump({
|
||||
"installed": ["test-ext"],
|
||||
"hooks": ["not", "a", "dict"]
|
||||
}))
|
||||
|
||||
|
||||
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}])
|
||||
monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True})
|
||||
monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"})
|
||||
@@ -64,9 +64,9 @@ def test_extension_update_corrupted_hooks_value(project_dir, monkeypatch):
|
||||
mock_zip = project_dir / "mock.zip"
|
||||
monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda self, ext_id: mock_zip)
|
||||
monkeypatch.setattr("typer.confirm", lambda _: True)
|
||||
|
||||
|
||||
result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir})
|
||||
|
||||
|
||||
# extension_update() catches exceptions internally and exits with code 1 on failure.
|
||||
assert result.exit_code == 1
|
||||
assert "AttributeError" not in result.output
|
||||
@@ -75,33 +75,33 @@ def test_extension_update_corrupted_hooks_value(project_dir, monkeypatch):
|
||||
def test_extension_update_rollback_corrupted_config(project_dir, monkeypatch):
|
||||
"""Regression: extension update rollback must handle corrupted extensions.yml."""
|
||||
monkeypatch.chdir(project_dir)
|
||||
|
||||
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
# Write config with hooks: null; get_project_config() normalizes this to {}
|
||||
# so the backup captures {} and the restored config will have hooks: {}.
|
||||
config_path.write_text(yaml.dump({"installed": ["test-ext"], "hooks": None}))
|
||||
|
||||
|
||||
# Mock update process to fail after backup
|
||||
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}])
|
||||
monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True})
|
||||
|
||||
|
||||
# Force failure in download_extension to trigger rollback
|
||||
def mock_download_fail(*args, **kwargs):
|
||||
# Corrupt the config BEFORE rollback is triggered
|
||||
config_path.write_text(yaml.dump("CORRUPTED"))
|
||||
raise Exception("Download failed")
|
||||
|
||||
|
||||
monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"})
|
||||
monkeypatch.setattr(ExtensionCatalog, "download_extension", mock_download_fail)
|
||||
monkeypatch.setattr("typer.confirm", lambda _: True)
|
||||
|
||||
|
||||
result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir})
|
||||
|
||||
|
||||
# Should handle Exception and NOT crash with AttributeError during rollback
|
||||
assert result.exit_code == 1
|
||||
assert "Download failed" in result.output
|
||||
assert not isinstance(result.exception, AttributeError)
|
||||
|
||||
|
||||
# Verify hooks key was preserved (normalized to {} if it was null/corrupted)
|
||||
restored_config = yaml.safe_load(config_path.read_text())
|
||||
assert isinstance(restored_config, dict)
|
||||
|
||||
@@ -2376,185 +2376,6 @@ Run {SCRIPT}
|
||||
assert ".specify/scripts/powershell/setup-plan.ps1 -Json" in content
|
||||
assert ".specify/scripts/bash/setup-plan.sh" not in content
|
||||
|
||||
@staticmethod
|
||||
def _make_subdir_extension(temp_dir, ext_id="echelon", aliases=None):
|
||||
"""Create an extension whose command body references bundled subdirs."""
|
||||
import yaml
|
||||
|
||||
ext_dir = temp_dir / ext_id
|
||||
ext_dir.mkdir()
|
||||
(ext_dir / "commands").mkdir()
|
||||
(ext_dir / "agents" / "control").mkdir(parents=True)
|
||||
(ext_dir / "knowledge-base").mkdir()
|
||||
(ext_dir / "templates").mkdir()
|
||||
(ext_dir / "specs" / "001-internal").mkdir(parents=True)
|
||||
|
||||
command = {
|
||||
"name": f"speckit.{ext_id}.run",
|
||||
"file": "commands/run.md",
|
||||
"description": "Run",
|
||||
}
|
||||
if aliases:
|
||||
command["aliases"] = aliases
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": ext_id,
|
||||
"name": "Echelon",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {"commands": [command]},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
(ext_dir / "commands" / "run.md").write_text(
|
||||
"---\ndescription: Run\n---\n\n"
|
||||
"Read agents/control/commander.md for instructions.\n"
|
||||
"Load knowledge-base/agent-scores.yaml for calibration.\n"
|
||||
"Use templates/kill-report.md as output format.\n"
|
||||
"Artifacts go to specs/001-internal/plan.md.\n"
|
||||
"See commands/run.md for the source.\n"
|
||||
)
|
||||
return ext_dir
|
||||
|
||||
def test_codex_skill_registration_rewrites_extension_subdir_paths(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Extension-relative subdir refs must point at the installed location."""
|
||||
ext_dir = self._make_subdir_extension(temp_dir)
|
||||
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
skills_dir.mkdir(parents=True)
|
||||
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
|
||||
content = (skills_dir / "speckit-echelon-run" / "SKILL.md").read_text()
|
||||
assert ".specify/extensions/echelon/agents/control/commander.md" in content
|
||||
assert ".specify/extensions/echelon/knowledge-base/agent-scores.yaml" in content
|
||||
assert ".specify/extensions/echelon/templates/kill-report.md" in content
|
||||
assert "Read agents/" not in content
|
||||
# specs/ refs point at the user's project artifacts, never the extension
|
||||
assert "to specs/001-internal/plan.md" in content
|
||||
assert ".specify/extensions/echelon/specs/" not in content
|
||||
# commands/ refs are slash-command sources, not runtime reads
|
||||
assert "See commands/run.md" in content
|
||||
|
||||
def test_skill_registration_rewrites_extension_subdir_paths_in_aliases(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Alias skills reuse the rewritten body."""
|
||||
ext_dir = self._make_subdir_extension(
|
||||
temp_dir, ext_id="ext-alias-paths", aliases=["speckit.ext-alias-paths.go"]
|
||||
)
|
||||
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
skills_dir.mkdir(parents=True)
|
||||
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
|
||||
alias_content = (
|
||||
skills_dir / "speckit-ext-alias-paths-go" / "SKILL.md"
|
||||
).read_text()
|
||||
assert (
|
||||
".specify/extensions/ext-alias-paths/agents/control/commander.md"
|
||||
in alias_content
|
||||
)
|
||||
assert "Read agents/" not in alias_content
|
||||
|
||||
def test_markdown_registration_rewrites_extension_subdir_paths(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Markdown-format agents get the same rewrite via the shared path."""
|
||||
ext_dir = self._make_subdir_extension(temp_dir, ext_id="ext-md-paths")
|
||||
|
||||
amp_dir = project_dir / ".agents" / "commands"
|
||||
amp_dir.mkdir(parents=True)
|
||||
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("amp", manifest, ext_dir, project_dir)
|
||||
|
||||
content = (amp_dir / "speckit.ext-md-paths.run.md").read_text()
|
||||
assert ".specify/extensions/ext-md-paths/agents/control/commander.md" in content
|
||||
assert "Read agents/" not in content
|
||||
|
||||
def test_rewrite_extension_paths_only_rewrites_existing_subdirs(self, temp_dir):
|
||||
"""Only directories present in the extension are rewritten."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
|
||||
ext_dir = temp_dir / "ext-existing"
|
||||
(ext_dir / "agents").mkdir(parents=True)
|
||||
(ext_dir / ".hidden").mkdir()
|
||||
|
||||
text = (
|
||||
"Read agents/one.md then knowledge-base/two.md.\n"
|
||||
"Also ./agents/three.md but not /agents/abs.md.\n"
|
||||
"Keep .hidden/secret.md alone.\n"
|
||||
)
|
||||
rewritten = AgentCommandRegistrar.rewrite_extension_paths(
|
||||
text, "ext-existing", ext_dir
|
||||
)
|
||||
|
||||
assert ".specify/extensions/ext-existing/agents/one.md" in rewritten
|
||||
assert "Also .specify/extensions/ext-existing/agents/three.md" in rewritten
|
||||
# absolute paths keep their meaning
|
||||
assert "not /agents/abs.md" in rewritten
|
||||
# knowledge-base/ does not exist in this extension: left untouched
|
||||
assert "then knowledge-base/two.md" in rewritten
|
||||
assert ".hidden/secret.md" in rewritten
|
||||
assert ".specify/extensions/ext-existing/.hidden/" not in rewritten
|
||||
|
||||
def test_rewrite_extension_paths_handles_regex_special_replacement_text(
|
||||
self, temp_dir
|
||||
):
|
||||
"""subdir/extension_id containing regex-replacement-special characters
|
||||
(e.g. backslash / group references) must not raise or be misinterpreted
|
||||
by re.sub's replacement template (#2101).
|
||||
|
||||
The subdir name uses brackets rather than a backslash: on Windows,
|
||||
"\\" is a path separator, so a subdir literally named "assets\\q"
|
||||
would create nested directories "assets/q" instead of a single
|
||||
directory, and iterdir() would then only discover "assets" - never
|
||||
exercising the intended replacement text. extension_id isn't used to
|
||||
create a directory, so it's free to contain a real backslash/"\\1"
|
||||
to verify the callable replacement treats it literally.
|
||||
"""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
|
||||
ext_dir = temp_dir / "ext-backslash"
|
||||
weird_subdir = "assets[q]"
|
||||
(ext_dir / weird_subdir).mkdir(parents=True)
|
||||
# sanity-check the cross-platform assumption above
|
||||
assert [p.name for p in ext_dir.iterdir()] == [weird_subdir]
|
||||
|
||||
text = f"Read {weird_subdir}/file.md but not /{weird_subdir}/abs.md.\n"
|
||||
rewritten = AgentCommandRegistrar.rewrite_extension_paths(
|
||||
text, "ext\\1", ext_dir
|
||||
)
|
||||
|
||||
assert f".specify/extensions/ext\\1/{weird_subdir}/file.md" in rewritten
|
||||
# absolute paths are still left untouched
|
||||
assert f"/{weird_subdir}/abs.md" in rewritten
|
||||
|
||||
def test_rewrite_extension_paths_missing_dir_returns_text(self, temp_dir):
|
||||
"""A missing extension directory leaves the text unchanged."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
|
||||
text = "Read agents/one.md."
|
||||
assert (
|
||||
AgentCommandRegistrar.rewrite_extension_paths(
|
||||
text, "gone", temp_dir / "does-not-exist"
|
||||
)
|
||||
== text
|
||||
)
|
||||
|
||||
def test_register_commands_for_copilot(self, extension_dir, project_dir):
|
||||
"""Test registering commands for Copilot agent with .agent.md extension."""
|
||||
# Create .github/agents directory (Copilot project)
|
||||
@@ -6603,42 +6424,6 @@ class TestExtensionPriorityCLI:
|
||||
plain = strip_ansi(result.output)
|
||||
assert "already has priority 5" in plain
|
||||
|
||||
def test_set_priority_repairs_corrupted_bool(self, extension_dir, project_dir):
|
||||
"""A corrupted boolean priority must be repaired, not skipped.
|
||||
|
||||
``isinstance(True, int)`` is True and ``True == 1`` in Python, so a
|
||||
stored ``True`` priority would short-circuit the ``already has
|
||||
priority 1`` skip path and never get rewritten to a real int —
|
||||
contradicting the comment that promises corrupted values are
|
||||
repaired. The guard must exclude bools (like normalize_priority).
|
||||
"""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False, priority=5
|
||||
)
|
||||
# Inject a corrupted boolean priority (True == 1).
|
||||
manager.registry.update("test-ext", {"priority": True})
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["extension", "set-priority", "test-ext", "1"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
plain = strip_ansi(result.output)
|
||||
# The corrupted bool must be repaired, not reported as already-set.
|
||||
assert "already has priority" not in plain
|
||||
assert "priority changed" in plain
|
||||
|
||||
# The stored value is now a real int, not a bool.
|
||||
reloaded = ExtensionManager(project_dir).registry.get("test-ext")
|
||||
assert reloaded["priority"] == 1
|
||||
assert not isinstance(reloaded["priority"], bool)
|
||||
|
||||
def test_set_priority_invalid_value(self, extension_dir, project_dir):
|
||||
"""Test set-priority rejects invalid priority values."""
|
||||
from typer.testing import CliRunner
|
||||
@@ -7843,56 +7628,3 @@ class TestConfigManagerNonMappingYaml:
|
||||
(ext_dir / "jira-config.yml").write_text("just a string\n", encoding="utf-8")
|
||||
executor = HookExecutor(tmp_path)
|
||||
assert executor._evaluate_condition("config.x is set", "jira") is False
|
||||
|
||||
|
||||
class TestConfigManagerEnvPrefixCollision:
|
||||
"""Prefix-colliding env vars must not crash or clobber nested config."""
|
||||
|
||||
def test_scalar_then_nested_yields_nested(self, tmp_path, monkeypatch):
|
||||
"""SPECKIT_X_CONNECTION=x then SPECKIT_X_CONNECTION_URL=y.
|
||||
|
||||
The scalar-first order previously raised TypeError ('str' object
|
||||
does not support item assignment) when the walk indexed into 'x'.
|
||||
"""
|
||||
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION", "x")
|
||||
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION_URL", "y")
|
||||
cm = ConfigManager(tmp_path, "testext")
|
||||
assert cm._get_env_config() == {"connection": {"url": "y"}}
|
||||
|
||||
def test_nested_then_scalar_does_not_clobber(self, tmp_path, monkeypatch):
|
||||
"""Reverse order previously returned {'connection': 'x'}, losing url."""
|
||||
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION_URL", "y")
|
||||
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION", "x")
|
||||
cm = ConfigManager(tmp_path, "testext")
|
||||
assert cm._get_env_config() == {"connection": {"url": "y"}}
|
||||
|
||||
def test_colliding_env_does_not_disable_hook_condition(self, tmp_path, monkeypatch):
|
||||
"""`config.connection.url is set` must stay True under colliding env.
|
||||
|
||||
Before the fix the TypeError propagated into should_execute_hook's
|
||||
blanket `except Exception: return False`, silently disabling the hook.
|
||||
"""
|
||||
ext_dir = tmp_path / ".specify" / "extensions" / "testext"
|
||||
ext_dir.mkdir(parents=True)
|
||||
(ext_dir / "testext-config.yml").write_text(
|
||||
"connection:\n url: https://example.com\n", encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION", "x")
|
||||
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION_URL", "y")
|
||||
executor = HookExecutor(tmp_path)
|
||||
# Exercise the public API: before the fix the TypeError was swallowed
|
||||
# by should_execute_hook's `except Exception: return False`, so the
|
||||
# hook was silently disabled (False); after the fix it returns True.
|
||||
assert executor.should_execute_hook(
|
||||
{"condition": "config.connection.url is set", "extension": "testext"}
|
||||
) is True
|
||||
|
||||
def test_malformed_env_names_ignored(self, tmp_path, monkeypatch):
|
||||
"""A name with no key (SPECKIT_X_) or empty parts (consecutive
|
||||
underscores) must not create an entry under an empty key."""
|
||||
monkeypatch.setenv("SPECKIT_TESTEXT_", "orphan") # no key at all
|
||||
monkeypatch.setenv("SPECKIT_TESTEXT_A__B", "z") # empty middle part
|
||||
cm = ConfigManager(tmp_path, "testext")
|
||||
cfg = cm._get_env_config()
|
||||
assert "" not in cfg
|
||||
assert cfg == {"a": {"b": "z"}}
|
||||
|
||||
@@ -10,12 +10,12 @@ def test_merge_json_files_type_mismatch_preservation(tmp_path):
|
||||
existing_file = tmp_path / "settings.json"
|
||||
# User might have overridden a setting with a simple string or different type
|
||||
existing_file.write_text('{"chat.editor.fontFamily": "CustomFont"}')
|
||||
|
||||
|
||||
# Template might expect a dict for the same key (hypothetically)
|
||||
new_settings = {
|
||||
"chat.editor.fontFamily": {"font": "TemplateFont"}
|
||||
}
|
||||
|
||||
|
||||
merged = merge_json_files(existing_file, new_settings)
|
||||
# Result is None because user settings were preserved and nothing else changed
|
||||
assert merged is None
|
||||
@@ -32,7 +32,7 @@ def test_merge_json_files_deep_nesting(tmp_path):
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
|
||||
new_settings = {
|
||||
"a": {
|
||||
"b": {
|
||||
@@ -41,7 +41,7 @@ def test_merge_json_files_deep_nesting(tmp_path):
|
||||
"e": 3 # New mid-level key
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
merged = merge_json_files(existing_file, new_settings)
|
||||
assert merged["a"]["b"]["c"] == 1
|
||||
assert merged["a"]["b"]["d"] == 2
|
||||
@@ -51,7 +51,7 @@ def test_merge_json_files_empty_existing(tmp_path):
|
||||
"""Merging into an empty/new file."""
|
||||
existing_file = tmp_path / "empty.json"
|
||||
existing_file.write_text("{}")
|
||||
|
||||
|
||||
new_settings = {"a": 1}
|
||||
merged = merge_json_files(existing_file, new_settings)
|
||||
assert merged == {"a": 1}
|
||||
@@ -74,7 +74,7 @@ def test_merge_vscode_realistic_scenario(tmp_path):
|
||||
} // User comment
|
||||
}
|
||||
""")
|
||||
|
||||
|
||||
template_settings = {
|
||||
"chat.promptFilesRecommendations": {
|
||||
"speckit.specify": True,
|
||||
@@ -84,14 +84,14 @@ def test_merge_vscode_realistic_scenario(tmp_path):
|
||||
".specify/scripts/bash/": True
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
merged = merge_json_files(existing_file, template_settings)
|
||||
|
||||
|
||||
# Check preservation
|
||||
assert merged["editor.fontSize"] == 12
|
||||
assert merged["files.exclude"]["**/.git"] is True
|
||||
assert merged["chat.promptFilesRecommendations"]["existing.tool"] is True
|
||||
|
||||
|
||||
# Check additions
|
||||
assert merged["chat.promptFilesRecommendations"]["speckit.specify"] is True
|
||||
assert merged["chat.tools.terminal.autoApprove"][".specify/scripts/bash/"] is True
|
||||
@@ -104,7 +104,7 @@ def test_merge_json_files_with_bom(tmp_path):
|
||||
content = '{"a": 1}'
|
||||
# Prepend UTF-8 BOM
|
||||
existing_file.write_bytes(b'\xef\xbb\xbf' + content.encode('utf-8'))
|
||||
|
||||
|
||||
new_settings = {"b": 2}
|
||||
merged = merge_json_files(existing_file, new_settings)
|
||||
assert merged == {"a": 1, "b": 2}
|
||||
@@ -113,7 +113,7 @@ def test_merge_json_files_not_a_dictionary_template(tmp_path):
|
||||
"""If for some reason new_content is not a dict, PRESERVE existing settings by returning None."""
|
||||
existing_file = tmp_path / "ok.json"
|
||||
existing_file.write_text('{"a": 1}')
|
||||
|
||||
|
||||
# Secure fallback: return None to skip writing and avoid clobbering
|
||||
assert merge_json_files(existing_file, ["not", "a", "dict"]) is None
|
||||
|
||||
@@ -121,7 +121,7 @@ def test_merge_json_files_unparseable_existing(tmp_path):
|
||||
"""If the existing file is unparseable JSON, return None to avoid overwriting it."""
|
||||
bad_file = tmp_path / "bad.json"
|
||||
bad_file.write_text('{"a": 1, missing_value}') # Invalid JSON
|
||||
|
||||
|
||||
assert merge_json_files(bad_file, {"b": 2}) is None
|
||||
|
||||
|
||||
@@ -129,11 +129,11 @@ def test_merge_json_files_list_preservation(tmp_path):
|
||||
"""Verify that existing list values are preserved and NOT merged or overwritten."""
|
||||
existing_file = tmp_path / "list.json"
|
||||
existing_file.write_text('{"my.list": ["user_item"]}')
|
||||
|
||||
|
||||
template_settings = {
|
||||
"my.list": ["template_item"]
|
||||
}
|
||||
|
||||
|
||||
merged = merge_json_files(existing_file, template_settings)
|
||||
# The polite merge policy says: keep existing values if they exist and aren't both dicts.
|
||||
# Since nothing changed, it returns None.
|
||||
@@ -143,12 +143,12 @@ def test_merge_json_files_no_changes(tmp_path):
|
||||
"""If the merge doesn't introduce any new keys or changes, return None to skip rewrite."""
|
||||
existing_file = tmp_path / "no_change.json"
|
||||
existing_file.write_text('{"a": 1, "b": {"c": 2}}')
|
||||
|
||||
|
||||
template_settings = {
|
||||
"a": 1, # Already exists
|
||||
"b": {"c": 2} # Already exists nested
|
||||
}
|
||||
|
||||
|
||||
# Should return None because result == existing
|
||||
assert merge_json_files(existing_file, template_settings) is None
|
||||
|
||||
@@ -156,11 +156,11 @@ def test_merge_json_files_type_mismatch_no_op(tmp_path):
|
||||
"""If a key exists with different type and we preserve it, it might still result in no change."""
|
||||
existing_file = tmp_path / "mismatch_no_op.json"
|
||||
existing_file.write_text('{"a": "user_string"}')
|
||||
|
||||
|
||||
template_settings = {
|
||||
"a": {"key": "template_dict"} # Mismatch, will be ignored
|
||||
}
|
||||
|
||||
|
||||
# Should return None because we preserved the user's string and nothing else changed
|
||||
assert merge_json_files(existing_file, template_settings) is None
|
||||
|
||||
|
||||
@@ -2620,7 +2620,7 @@ class TestSelfTestPreset:
|
||||
assert manifest.id == "self-test"
|
||||
assert manifest.name == "Self-Test Preset"
|
||||
assert manifest.version == "1.0.0"
|
||||
assert len(manifest.templates) == 7 # 5 templates + 2 commands
|
||||
assert len(manifest.templates) == 8 # 6 templates + 2 commands
|
||||
|
||||
def test_self_test_provides_all_core_templates(self):
|
||||
"""Verify the self-test preset provides an override for every core template."""
|
||||
@@ -3663,8 +3663,6 @@ class TestPresetSkills:
|
||||
|
||||
extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
|
||||
(extension_dir / "commands").mkdir(parents=True, exist_ok=True)
|
||||
(extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True)
|
||||
(extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n")
|
||||
(extension_dir / "commands" / "cmd.md").write_text(
|
||||
"---\n"
|
||||
"description: Extension fakeext cmd\n"
|
||||
@@ -3673,7 +3671,6 @@ class TestPresetSkills:
|
||||
"---\n\n"
|
||||
"extension:fakeext\n"
|
||||
"Run {SCRIPT}\n"
|
||||
"Read agents/control/commander.md for context.\n"
|
||||
)
|
||||
extension_manifest = {
|
||||
"schema_version": "1.0",
|
||||
@@ -3739,92 +3736,8 @@ class TestPresetSkills:
|
||||
assert "source: extension:fakeext" in content
|
||||
assert "extension:fakeext" in content
|
||||
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
|
||||
# Extension-relative subdir references must resolve to their
|
||||
# installed location on restore too (#2101), not just on first
|
||||
# registration.
|
||||
assert ".specify/extensions/fakeext/agents/control/commander.md" in content
|
||||
assert "Read agents/control" not in content
|
||||
assert "# Fakeext Cmd Skill" in content
|
||||
|
||||
def test_skill_composed_over_extension_base_rewrites_subdir_paths(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""When a preset composes (append) over an extension-provided base
|
||||
command, the resulting skill (read from the .composed output) must
|
||||
still resolve the extension's own subdir references (#2101), not
|
||||
just when the extension wins outright (replace)."""
|
||||
self._write_init_options(project_dir, ai="codex")
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
self._create_skill(skills_dir, "speckit-fakeext-cmd", body="original extension skill")
|
||||
|
||||
extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
|
||||
(extension_dir / "commands").mkdir(parents=True, exist_ok=True)
|
||||
(extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True)
|
||||
(extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n")
|
||||
(extension_dir / "commands" / "cmd.md").write_text(
|
||||
"---\ndescription: Extension fakeext cmd\n---\n\n"
|
||||
"Read agents/control/commander.md for context.\n"
|
||||
)
|
||||
extension_manifest = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "fakeext",
|
||||
"name": "Fake Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.fakeext.cmd",
|
||||
"file": "commands/cmd.md",
|
||||
"description": "Fake extension command",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(extension_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(extension_manifest, f)
|
||||
|
||||
preset_dir = temp_dir / "ext-base-append-skill"
|
||||
preset_dir.mkdir()
|
||||
(preset_dir / "commands").mkdir()
|
||||
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
|
||||
"---\ndescription: Preset overlay\n---\n\n## Extra\n"
|
||||
)
|
||||
preset_manifest = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "ext-base-append-skill",
|
||||
"name": "Ext Base Append Skill",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "command",
|
||||
"name": "speckit.fakeext.cmd",
|
||||
"file": "commands/speckit.fakeext.cmd.md",
|
||||
"strategy": "append",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(preset_dir / "preset.yml", "w") as f:
|
||||
yaml.dump(preset_manifest, f)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
|
||||
skill_file = skills_dir / "speckit-fakeext-cmd" / "SKILL.md"
|
||||
content = skill_file.read_text()
|
||||
assert ".specify/extensions/fakeext/agents/control/commander.md" in content
|
||||
assert "Read agents/control" not in content
|
||||
assert "## Extra" in content
|
||||
|
||||
def test_preset_remove_skips_skill_dir_without_skill_file(self, project_dir, temp_dir):
|
||||
"""Preset removal should not delete arbitrary directories missing SKILL.md."""
|
||||
self._write_init_options(project_dir, ai="codex")
|
||||
@@ -4147,40 +4060,6 @@ class TestPresetSetPriority:
|
||||
plain = strip_ansi(result.output)
|
||||
assert "already has priority 5" in plain
|
||||
|
||||
def test_set_priority_repairs_corrupted_bool(self, project_dir, pack_dir):
|
||||
"""A corrupted boolean priority must be repaired, not skipped.
|
||||
|
||||
``isinstance(True, int)`` is True and ``True == 1`` in Python, so a
|
||||
stored ``True`` priority would short-circuit the ``already has
|
||||
priority 1`` skip path and never get rewritten to a real int —
|
||||
contradicting the comment that promises corrupted values are
|
||||
repaired. The guard must exclude bools (like normalize_priority).
|
||||
"""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5", priority=5)
|
||||
# Inject a corrupted boolean priority (True == 1).
|
||||
manager.registry.update("test-pack", {"priority": True})
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["preset", "set-priority", "test-pack", "1"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
plain = strip_ansi(result.output)
|
||||
# The corrupted bool must be repaired, not reported as already-set.
|
||||
assert "already has priority" not in plain
|
||||
assert "priority changed" in plain
|
||||
|
||||
# The stored value is now a real int, not a bool.
|
||||
reloaded = PresetManager(project_dir).registry.get("test-pack")
|
||||
assert reloaded["priority"] == 1
|
||||
assert not isinstance(reloaded["priority"], bool)
|
||||
|
||||
def test_set_priority_invalid_value(self, project_dir, pack_dir):
|
||||
"""Test set-priority rejects invalid priority values."""
|
||||
from typer.testing import CliRunner
|
||||
@@ -6059,86 +5938,6 @@ class TestResolveContent:
|
||||
content = resolver.resolve_content("spec-template")
|
||||
assert content == "# Replaced content\n"
|
||||
|
||||
@pytest.mark.parametrize("strategy", ["append", "prepend", "wrap"])
|
||||
def test_resolve_content_rewrites_extension_base_subdir_paths(
|
||||
self, project_dir, temp_dir, strategy
|
||||
):
|
||||
"""Composing over an extension-provided base command must resolve the
|
||||
extension's own subdir references (agents/, knowledge-base/) to their
|
||||
installed location (#2101), not just when the extension wins outright.
|
||||
"""
|
||||
extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
|
||||
(extension_dir / "commands").mkdir(parents=True, exist_ok=True)
|
||||
(extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True)
|
||||
(extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n")
|
||||
(extension_dir / "commands" / "cmd.md").write_text(
|
||||
"---\ndescription: Extension fakeext cmd\n---\n\n"
|
||||
"Read agents/control/commander.md for context.\n"
|
||||
)
|
||||
extension_manifest = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "fakeext",
|
||||
"name": "Fake Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.fakeext.cmd",
|
||||
"file": "commands/cmd.md",
|
||||
"description": "Fake extension command",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(extension_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(extension_manifest, f)
|
||||
|
||||
preset_dir = temp_dir / f"ext-base-{strategy}"
|
||||
preset_dir.mkdir()
|
||||
(preset_dir / "commands").mkdir()
|
||||
overlay_body = (
|
||||
"{CORE_TEMPLATE}\n## Extra\n" if strategy == "wrap" else "## Extra\n"
|
||||
)
|
||||
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
|
||||
f"---\ndescription: Preset overlay\n---\n\n{overlay_body}"
|
||||
)
|
||||
preset_manifest = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": f"ext-base-{strategy}",
|
||||
"name": "Ext Base",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "command",
|
||||
"name": "speckit.fakeext.cmd",
|
||||
"file": "commands/speckit.fakeext.cmd.md",
|
||||
"strategy": strategy,
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(preset_dir / "preset.yml", "w") as f:
|
||||
yaml.dump(preset_manifest, f)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
|
||||
resolver = PresetResolver(project_dir)
|
||||
content = resolver.resolve_content("speckit.fakeext.cmd", "command")
|
||||
assert content is not None
|
||||
assert ".specify/extensions/fakeext/agents/control/commander.md" in content
|
||||
assert "Read agents/control" not in content
|
||||
assert "## Extra" in content
|
||||
|
||||
|
||||
class TestCollectAllLayers:
|
||||
"""Test PresetResolver.collect_all_layers() method."""
|
||||
@@ -6219,172 +6018,6 @@ class TestCollectAllLayers:
|
||||
class TestRemoveReconciliation:
|
||||
"""Test that removing a preset re-registers the next layer's command."""
|
||||
|
||||
def test_remove_restores_extension_command_subdir_paths_for_non_skill_agent(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""When a preset override of an extension command is removed, the
|
||||
reconciled non-skill-agent command file should have the extension's
|
||||
own subdir references rewritten to their installed location (#2101),
|
||||
not left as bare, unresolvable paths."""
|
||||
gemini_dir = project_dir / ".gemini" / "commands"
|
||||
gemini_dir.mkdir(parents=True)
|
||||
|
||||
extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
|
||||
(extension_dir / "commands").mkdir(parents=True, exist_ok=True)
|
||||
(extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True)
|
||||
(extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n")
|
||||
(extension_dir / "commands" / "cmd.md").write_text(
|
||||
"---\ndescription: Extension fakeext cmd\n---\n\n"
|
||||
"Read agents/control/commander.md for context.\n"
|
||||
)
|
||||
extension_manifest = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "fakeext",
|
||||
"name": "Fake Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.fakeext.cmd",
|
||||
"file": "commands/cmd.md",
|
||||
"description": "Fake extension command",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(extension_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(extension_manifest, f)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
|
||||
preset_dir = temp_dir / "ext-cmd-override"
|
||||
preset_dir.mkdir()
|
||||
(preset_dir / "commands").mkdir()
|
||||
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
|
||||
"---\ndescription: Override fakeext cmd\n---\n\npreset override content\n"
|
||||
)
|
||||
preset_manifest = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "ext-cmd-override",
|
||||
"name": "Ext Cmd Override",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "command",
|
||||
"name": "speckit.fakeext.cmd",
|
||||
"file": "commands/speckit.fakeext.cmd.md",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(preset_dir / "preset.yml", "w") as f:
|
||||
yaml.dump(preset_manifest, f)
|
||||
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
|
||||
cmd_files = list(gemini_dir.glob("*fakeext*"))
|
||||
assert cmd_files, "Command file should exist in gemini dir"
|
||||
assert "preset override content" in cmd_files[0].read_text()
|
||||
|
||||
manager.remove("ext-cmd-override")
|
||||
|
||||
cmd_files = list(gemini_dir.glob("*fakeext*"))
|
||||
assert cmd_files, "Command file should still exist after removal"
|
||||
content = cmd_files[0].read_text()
|
||||
assert "preset override content" not in content
|
||||
assert ".specify/extensions/fakeext/agents/control/commander.md" in content
|
||||
assert "Read agents/control" not in content
|
||||
|
||||
def test_install_composes_extension_command_and_rewrites_subdir_paths_for_non_skill_agent(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""When a preset overlays (append) an extension-provided base command,
|
||||
the initial composed non-skill-agent command file must have the
|
||||
extension's own subdir references rewritten to their installed
|
||||
location (#2101), matching the live repro: extension body
|
||||
'Read agents/control/commander.md', preset appends to
|
||||
speckit.fakeext.cmd, generated Gemini content retains the bare path."""
|
||||
gemini_dir = project_dir / ".gemini" / "commands"
|
||||
gemini_dir.mkdir(parents=True)
|
||||
|
||||
extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
|
||||
(extension_dir / "commands").mkdir(parents=True, exist_ok=True)
|
||||
(extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True)
|
||||
(extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n")
|
||||
(extension_dir / "commands" / "cmd.md").write_text(
|
||||
"---\ndescription: Extension fakeext cmd\n---\n\n"
|
||||
"Read agents/control/commander.md for context.\n"
|
||||
)
|
||||
extension_manifest = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "fakeext",
|
||||
"name": "Fake Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.fakeext.cmd",
|
||||
"file": "commands/cmd.md",
|
||||
"description": "Fake extension command",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(extension_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(extension_manifest, f)
|
||||
|
||||
preset_dir = temp_dir / "ext-cmd-append"
|
||||
preset_dir.mkdir()
|
||||
(preset_dir / "commands").mkdir()
|
||||
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
|
||||
"---\ndescription: Append fakeext cmd\n---\n\n## Extra\n"
|
||||
)
|
||||
preset_manifest = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "ext-cmd-append",
|
||||
"name": "Ext Cmd Append",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "command",
|
||||
"name": "speckit.fakeext.cmd",
|
||||
"file": "commands/speckit.fakeext.cmd.md",
|
||||
"strategy": "append",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(preset_dir / "preset.yml", "w") as f:
|
||||
yaml.dump(preset_manifest, f)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
|
||||
cmd_files = list(gemini_dir.glob("*fakeext*"))
|
||||
assert cmd_files, "Command file should exist in gemini dir"
|
||||
content = cmd_files[0].read_text()
|
||||
assert ".specify/extensions/fakeext/agents/control/commander.md" in content
|
||||
assert "Read agents/control" not in content
|
||||
assert "## Extra" in content
|
||||
|
||||
def test_remove_restores_lower_priority_command(
|
||||
self, project_dir, temp_dir, valid_pack_data
|
||||
):
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"""Tests for setup-tasks.{sh,ps1} template resolution and feature resolution."""
|
||||
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
from tests.conftest import requires_bash
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
|
||||
SETUP_TASKS_SH = PROJECT_ROOT / "scripts" / "bash" / "setup-tasks.sh"
|
||||
@@ -18,38 +18,38 @@ COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
SETUP_TASKS_PS = PROJECT_ROOT / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
CHECK_PREREQ_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
TASKS_TEMPLATE = PROJECT_ROOT / "templates" / "tasks-template.md"
|
||||
|
||||
|
||||
HAS_PWSH = shutil.which("pwsh") is not None
|
||||
_WINDOWS_POWERSHELL = (shutil.which("powershell.exe") or shutil.which("powershell")) if os.name == "nt" else None
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _install_bash_scripts(repo: Path) -> None:
|
||||
d = repo / ".specify" / "scripts" / "bash"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(COMMON_SH, d / "common.sh")
|
||||
shutil.copy(SETUP_TASKS_SH, d / "setup-tasks.sh")
|
||||
shutil.copy(CHECK_PREREQ_SH, d / "check-prerequisites.sh")
|
||||
|
||||
|
||||
|
||||
|
||||
def _install_ps_scripts(repo: Path) -> None:
|
||||
d = repo / ".specify" / "scripts" / "powershell"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(COMMON_PS, d / "common.ps1")
|
||||
shutil.copy(SETUP_TASKS_PS, d / "setup-tasks.ps1")
|
||||
shutil.copy(CHECK_PREREQ_PS, d / "check-prerequisites.ps1")
|
||||
|
||||
|
||||
|
||||
|
||||
def _install_core_tasks_template(repo: Path) -> None:
|
||||
"""Copy the real tasks-template.md into the core template location."""
|
||||
tdir = repo / ".specify" / "templates"
|
||||
tdir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(TASKS_TEMPLATE, tdir / "tasks-template.md")
|
||||
|
||||
|
||||
|
||||
|
||||
def _write_feature_json(
|
||||
repo: Path, feature_directory: str = "specs/001-my-feature"
|
||||
) -> None:
|
||||
@@ -90,8 +90,8 @@ def _write_integration_state(repo: Path, integration: str = "claude", separator:
|
||||
json.dumps(state),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
def _clean_env() -> dict[str, str]:
|
||||
"""
|
||||
Return os.environ with all SPECIFY_* variables stripped so the scripts
|
||||
@@ -102,8 +102,8 @@ def _clean_env() -> dict[str, str]:
|
||||
if key.startswith("SPECIFY_"):
|
||||
env.pop(key)
|
||||
return env
|
||||
|
||||
|
||||
|
||||
|
||||
def _run_bash_format_command(repo: Path, command_name: str) -> subprocess.CompletedProcess:
|
||||
script = repo / ".specify" / "scripts" / "bash" / "common.sh"
|
||||
return subprocess.run(
|
||||
@@ -145,12 +145,12 @@ def _git_init(repo: Path) -> None:
|
||||
subprocess.run(
|
||||
["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixture
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tasks_repo(tmp_path: Path) -> Path:
|
||||
"""
|
||||
@@ -162,7 +162,7 @@ def tasks_repo(tmp_path: Path) -> Path:
|
||||
repo = tmp_path / "proj"
|
||||
repo.mkdir()
|
||||
_git_init(repo)
|
||||
|
||||
|
||||
# Keep a numbered branch name in this repo fixture; setup-tasks now resolves
|
||||
# feature directories from repository state rather than validating git branches.
|
||||
subprocess.run(
|
||||
@@ -170,18 +170,18 @@ def tasks_repo(tmp_path: Path) -> Path:
|
||||
cwd=repo,
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
(repo / ".specify").mkdir()
|
||||
_install_core_tasks_template(repo)
|
||||
_install_bash_scripts(repo)
|
||||
_install_ps_scripts(repo)
|
||||
return repo
|
||||
|
||||
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# BASH TESTS
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_tasks_bash_core_template_resolved(tasks_repo: Path) -> None:
|
||||
"""
|
||||
@@ -191,7 +191,7 @@ def test_setup_tasks_bash_core_template_resolved(tasks_repo: Path) -> None:
|
||||
"""
|
||||
_minimal_feature(tasks_repo)
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json"],
|
||||
cwd=tasks_repo,
|
||||
@@ -200,16 +200,16 @@ def test_setup_tasks_bash_core_template_resolved(tasks_repo: Path) -> None:
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
|
||||
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
|
||||
assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file"
|
||||
assert tasks_tmpl.name == "tasks-template.md"
|
||||
|
||||
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_tasks_bash_override_wins(tasks_repo: Path) -> None:
|
||||
"""
|
||||
@@ -217,15 +217,15 @@ def test_setup_tasks_bash_override_wins(tasks_repo: Path) -> None:
|
||||
setup-tasks.sh --json must return the override path, not the core path.
|
||||
"""
|
||||
_minimal_feature(tasks_repo)
|
||||
|
||||
|
||||
# Create the override
|
||||
overrides_dir = tasks_repo / ".specify" / "templates" / "overrides"
|
||||
overrides_dir.mkdir(parents=True, exist_ok=True)
|
||||
override_file = overrides_dir / "tasks-template.md"
|
||||
override_file.write_text("# override tasks template\n", encoding="utf-8")
|
||||
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json"],
|
||||
cwd=tasks_repo,
|
||||
@@ -234,9 +234,9 @@ def test_setup_tasks_bash_override_wins(tasks_repo: Path) -> None:
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
|
||||
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
|
||||
@@ -245,8 +245,8 @@ def test_setup_tasks_bash_override_wins(tasks_repo: Path) -> None:
|
||||
assert "overrides" in tasks_tmpl.parts, (
|
||||
f"Expected override path but got: {tasks_tmpl}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_tasks_bash_extension_wins_over_core(tasks_repo: Path) -> None:
|
||||
"""
|
||||
@@ -254,7 +254,7 @@ def test_setup_tasks_bash_extension_wins_over_core(tasks_repo: Path) -> None:
|
||||
tasks-template.md from the extension before falling back to the core path.
|
||||
"""
|
||||
_minimal_feature(tasks_repo)
|
||||
|
||||
|
||||
# FIX: real extension layout is .specify/extensions/<id>/templates/<name>.md
|
||||
extension_dir = (
|
||||
tasks_repo / ".specify" / "extensions" / "test-extension" / "templates"
|
||||
@@ -262,9 +262,9 @@ def test_setup_tasks_bash_extension_wins_over_core(tasks_repo: Path) -> None:
|
||||
extension_dir.mkdir(parents=True, exist_ok=True)
|
||||
extension_file = extension_dir / "tasks-template.md"
|
||||
extension_file.write_text("# extension tasks template\n", encoding="utf-8")
|
||||
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json"],
|
||||
cwd=tasks_repo,
|
||||
@@ -273,9 +273,9 @@ def test_setup_tasks_bash_extension_wins_over_core(tasks_repo: Path) -> None:
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
|
||||
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
|
||||
@@ -283,8 +283,8 @@ def test_setup_tasks_bash_extension_wins_over_core(tasks_repo: Path) -> None:
|
||||
assert tasks_tmpl == extension_file.resolve(), (
|
||||
f"Expected extension path but got: {tasks_tmpl}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_tasks_bash_preset_wins_over_extension(tasks_repo: Path) -> None:
|
||||
"""
|
||||
@@ -292,7 +292,7 @@ def test_setup_tasks_bash_preset_wins_over_extension(tasks_repo: Path) -> None:
|
||||
resolve the preset path because presets outrank extensions.
|
||||
"""
|
||||
_minimal_feature(tasks_repo)
|
||||
|
||||
|
||||
# FIX: real extension layout is .specify/extensions/<id>/templates/<name>.md
|
||||
extension_dir = (
|
||||
tasks_repo / ".specify" / "extensions" / "test-extension" / "templates"
|
||||
@@ -300,15 +300,15 @@ def test_setup_tasks_bash_preset_wins_over_extension(tasks_repo: Path) -> None:
|
||||
extension_dir.mkdir(parents=True, exist_ok=True)
|
||||
extension_file = extension_dir / "tasks-template.md"
|
||||
extension_file.write_text("# extension tasks template\n", encoding="utf-8")
|
||||
|
||||
|
||||
# FIX: real preset layout is .specify/presets/<id>/templates/<name>.md
|
||||
preset_dir = tasks_repo / ".specify" / "presets" / "test-preset" / "templates"
|
||||
preset_dir.mkdir(parents=True, exist_ok=True)
|
||||
preset_file = preset_dir / "tasks-template.md"
|
||||
preset_file.write_text("# preset tasks template\n", encoding="utf-8")
|
||||
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json"],
|
||||
cwd=tasks_repo,
|
||||
@@ -317,9 +317,9 @@ def test_setup_tasks_bash_preset_wins_over_extension(tasks_repo: Path) -> None:
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
|
||||
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
|
||||
@@ -327,8 +327,8 @@ def test_setup_tasks_bash_preset_wins_over_extension(tasks_repo: Path) -> None:
|
||||
assert tasks_tmpl == preset_file.resolve(), (
|
||||
f"Expected preset path but got: {tasks_tmpl}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None:
|
||||
"""
|
||||
@@ -336,7 +336,7 @@ def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None:
|
||||
.specify/presets/.registry wins.
|
||||
"""
|
||||
_minimal_feature(tasks_repo)
|
||||
|
||||
|
||||
# resolve_template reads .specify/presets/.registry as a JSON object with a
|
||||
# "presets" map where each entry has a numeric "priority" (lower = higher
|
||||
# precedence). Create two presets; priority-1-preset wins over priority-2-preset.
|
||||
@@ -349,7 +349,7 @@ def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None:
|
||||
low_priority_dir = (
|
||||
tasks_repo / ".specify" / "presets" / "priority-2-preset" / "templates"
|
||||
)
|
||||
|
||||
|
||||
low_priority_dir.mkdir(parents=True, exist_ok=True)
|
||||
low_priority_file = low_priority_dir / "tasks-template.md"
|
||||
low_priority_file.write_text("# low priority preset tasks template\n", encoding="utf-8")
|
||||
@@ -366,9 +366,9 @@ def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None:
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json"],
|
||||
cwd=tasks_repo,
|
||||
@@ -377,9 +377,9 @@ def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None:
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
|
||||
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
|
||||
@@ -387,8 +387,8 @@ def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None:
|
||||
assert tasks_tmpl == high_priority_file.resolve(), (
|
||||
f"Expected high-priority preset path but got: {tasks_tmpl}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_tasks_bash_missing_template_errors(tasks_repo: Path) -> None:
|
||||
"""
|
||||
@@ -396,13 +396,13 @@ def test_setup_tasks_bash_missing_template_errors(tasks_repo: Path) -> None:
|
||||
exit non-zero and print a helpful ERROR message to stderr.
|
||||
"""
|
||||
_minimal_feature(tasks_repo)
|
||||
|
||||
|
||||
# Remove the core template so no template exists anywhere
|
||||
core = tasks_repo / ".specify" / "templates" / "tasks-template.md"
|
||||
core.unlink()
|
||||
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json"],
|
||||
cwd=tasks_repo,
|
||||
@@ -415,8 +415,8 @@ def test_setup_tasks_bash_missing_template_errors(tasks_repo: Path) -> None:
|
||||
assert result.returncode != 0
|
||||
assert "ERROR" in result.stderr
|
||||
assert "tasks-template" in result.stderr
|
||||
|
||||
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_bash_command_hint_defaults_to_dot_without_integration_json(tasks_repo: Path) -> None:
|
||||
integration_json = tasks_repo / ".specify" / "integration.json"
|
||||
@@ -619,15 +619,15 @@ def test_setup_tasks_bash_passes_custom_branch_when_feature_json_valid(
|
||||
cwd=tasks_repo,
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
feat = tasks_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
_write_feature_json(tasks_repo)
|
||||
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json"],
|
||||
cwd=tasks_repo,
|
||||
@@ -636,10 +636,10 @@ def test_setup_tasks_bash_passes_custom_branch_when_feature_json_valid(
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
|
||||
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_tasks_bash_errors_without_feature_context(
|
||||
tasks_repo: Path,
|
||||
@@ -663,11 +663,11 @@ def test_setup_tasks_bash_errors_without_feature_context(
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "Feature directory not found" in result.stderr
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# POWERSHELL TESTS
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None:
|
||||
"""
|
||||
@@ -678,7 +678,7 @@ def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None:
|
||||
_minimal_feature(tasks_repo)
|
||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
|
||||
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
cwd=tasks_repo,
|
||||
@@ -687,16 +687,16 @@ def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None:
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
|
||||
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
|
||||
assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file"
|
||||
assert tasks_tmpl.name == "tasks-template.md"
|
||||
|
||||
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None:
|
||||
"""
|
||||
@@ -704,15 +704,15 @@ def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None:
|
||||
setup-tasks.ps1 -Json must return the override path, not the core path.
|
||||
"""
|
||||
_minimal_feature(tasks_repo)
|
||||
|
||||
|
||||
overrides_dir = tasks_repo / ".specify" / "templates" / "overrides"
|
||||
overrides_dir.mkdir(parents=True, exist_ok=True)
|
||||
override_file = overrides_dir / "tasks-template.md"
|
||||
override_file.write_text("# override tasks template\n", encoding="utf-8")
|
||||
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
|
||||
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
cwd=tasks_repo,
|
||||
@@ -721,9 +721,9 @@ def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None:
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
|
||||
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
|
||||
@@ -731,8 +731,8 @@ def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None:
|
||||
assert "overrides" in tasks_tmpl.parts, (
|
||||
f"Expected override path but got: {tasks_tmpl}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None:
|
||||
"""
|
||||
@@ -740,13 +740,13 @@ def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None:
|
||||
exit non-zero and write a helpful error to stderr.
|
||||
"""
|
||||
_minimal_feature(tasks_repo)
|
||||
|
||||
|
||||
core = tasks_repo / ".specify" / "templates" / "tasks-template.md"
|
||||
core.unlink()
|
||||
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
|
||||
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
cwd=tasks_repo,
|
||||
@@ -755,11 +755,11 @@ def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None:
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "tasks-template" in result.stderr.lower() or "tasks-template" in result.stdout.lower()
|
||||
|
||||
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_powershell_command_hint_normalizes_mixed_separators(
|
||||
tasks_repo: Path,
|
||||
@@ -855,16 +855,16 @@ def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
|
||||
cwd=tasks_repo,
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
feat = tasks_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
_write_feature_json(tasks_repo)
|
||||
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
|
||||
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
cwd=tasks_repo,
|
||||
@@ -873,10 +873,10 @@ def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
|
||||
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_setup_tasks_ps_errors_without_feature_context(
|
||||
tasks_repo: Path,
|
||||
|
||||
@@ -1321,3 +1321,4 @@ class TestDescriptionQuoting:
|
||||
"""Plain description without special characters continues to work."""
|
||||
result = run_script(git_repo, "--dry-run", "--short-name", "feat", "Add login feature")
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
@@ -460,34 +460,6 @@ class TestExpressions:
|
||||
assert evaluate_expression("{{ inputs.s | contains('ab') }}", ctx2) is True
|
||||
assert evaluate_expression("{{ inputs.missing | default('a|b') }}", ctx2) == "a|b"
|
||||
|
||||
def test_membership_against_non_iterable_is_false_not_error(self):
|
||||
from specify_cli.workflows.expressions import (
|
||||
evaluate_condition,
|
||||
evaluate_expression,
|
||||
)
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
# A non-iterable right operand (int, bool, None, float) makes a raw
|
||||
# `x in y` raise TypeError in Python. The evaluator must treat it as
|
||||
# "not contained" (False, and `not in` as True) instead of leaking the
|
||||
# TypeError and crashing the whole workflow run. This generalizes the
|
||||
# previous `right is not None` guard and mirrors _safe_compare, which
|
||||
# already swallows TypeError for the ordering operators.
|
||||
ctx = StepContext(inputs={"tag": "x", "count": 5, "ratio": 1.5, "flag": True})
|
||||
assert evaluate_expression("{{ inputs.tag in inputs.count }}", ctx) is False
|
||||
assert evaluate_expression("{{ inputs.tag not in inputs.count }}", ctx) is True
|
||||
assert evaluate_expression("{{ 'a' in inputs.ratio }}", ctx) is False
|
||||
assert evaluate_expression("{{ 'a' in inputs.flag }}", ctx) is False
|
||||
assert evaluate_expression("{{ inputs.tag in inputs.missing }}", ctx) is False
|
||||
# A condition that would otherwise crash the run now evaluates cleanly.
|
||||
assert evaluate_condition("{{ inputs.tag in inputs.count }}", ctx) is False
|
||||
|
||||
# Regression: genuine membership over a real iterable still works.
|
||||
ok = StepContext(inputs={"items": ["x", "y"], "s": "xyz"})
|
||||
assert evaluate_expression("{{ 'x' in inputs.items }}", ok) is True
|
||||
assert evaluate_expression("{{ 'z' not in inputs.items }}", ok) is True
|
||||
assert evaluate_expression("{{ 'y' in inputs.s }}", ok) is True
|
||||
|
||||
def test_filter_default(self):
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
@@ -1381,6 +1353,7 @@ class TestShellStep:
|
||||
assert step.validate({"id": "s", "run": "echo hi"}) == []
|
||||
assert step.validate({"id": "s", "run": "{{ steps.x.output }}"}) == []
|
||||
|
||||
|
||||
def test_output_format_json_exposes_data(self, tmp_path):
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
@@ -1437,130 +1410,6 @@ class TestShellStep:
|
||||
errors = step.validate({"id": "emit", "run": "exit 0", "output_format": "yaml"})
|
||||
assert any("'output_format' must be 'json'" in e for e in errors)
|
||||
|
||||
def test_configured_timeout_is_passed_to_subprocess(self, monkeypatch):
|
||||
"""A ``timeout:`` value on the step overrides the 300s default and is
|
||||
threaded through to ``subprocess.run`` (issue #3327)."""
|
||||
import subprocess
|
||||
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
captured["timeout"] = kwargs.get("timeout")
|
||||
return subprocess.CompletedProcess(
|
||||
args=args[0] if args else "", returncode=0, stdout="", stderr=""
|
||||
)
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
step = ShellStep()
|
||||
result = step.execute(
|
||||
{"id": "qa", "run": "echo hi", "timeout": 1800}, StepContext()
|
||||
)
|
||||
assert result.status == StepStatus.COMPLETED
|
||||
assert captured["timeout"] == 1800
|
||||
|
||||
def test_default_timeout_preserved_when_omitted(self, monkeypatch):
|
||||
"""Omitting ``timeout:`` preserves the historical 300s default."""
|
||||
import subprocess
|
||||
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
captured["timeout"] = kwargs.get("timeout")
|
||||
return subprocess.CompletedProcess(
|
||||
args=args[0] if args else "", returncode=0, stdout="", stderr=""
|
||||
)
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
step = ShellStep()
|
||||
step.execute({"id": "qa", "run": "echo hi"}, StepContext())
|
||||
assert captured["timeout"] == 300
|
||||
|
||||
def test_timeout_error_reports_configured_value(self, monkeypatch):
|
||||
"""The timeout failure message reflects the configured duration, not a
|
||||
hardcoded 300."""
|
||||
import subprocess
|
||||
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
raise subprocess.TimeoutExpired(cmd="echo hi", timeout=5)
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
step = ShellStep()
|
||||
result = step.execute(
|
||||
{"id": "qa", "run": "echo hi", "timeout": 5}, StepContext()
|
||||
)
|
||||
assert result.status == StepStatus.FAILED
|
||||
assert "5 seconds" in (result.error or "")
|
||||
|
||||
def test_execute_fails_cleanly_on_invalid_timeout(self, monkeypatch):
|
||||
"""execute() must fail the step (not raise) on an invalid timeout even
|
||||
when validate() was skipped — the engine does not auto-validate step
|
||||
config, so an unvalidated string/bool/non-finite timeout would
|
||||
otherwise crash subprocess.run() and take down the whole run."""
|
||||
import subprocess
|
||||
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
def fail_if_called(*args, **kwargs):
|
||||
raise AssertionError("subprocess.run should not run on invalid timeout")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fail_if_called)
|
||||
step = ShellStep()
|
||||
# A string would raise TypeError; ``True`` would silently become a 1s
|
||||
# timeout (bool is an int subclass); ``.inf`` would raise at runtime.
|
||||
for bad in ("30", True, float("inf"), 0):
|
||||
result = step.execute(
|
||||
{"id": "qa", "run": "echo hi", "timeout": bad}, StepContext()
|
||||
)
|
||||
assert result.status == StepStatus.FAILED
|
||||
assert "'timeout' must be a positive number" in (result.error or "")
|
||||
|
||||
def test_validate_rejects_non_positive_timeout(self):
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
|
||||
step = ShellStep()
|
||||
for bad in (0, -30):
|
||||
errors = step.validate({"id": "qa", "run": "echo hi", "timeout": bad})
|
||||
assert any("'timeout' must be a positive number" in e for e in errors)
|
||||
|
||||
def test_validate_rejects_non_numeric_timeout(self):
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
|
||||
step = ShellStep()
|
||||
# A string and a bool are both invalid (bool is an int subclass but a
|
||||
# config error, not a duration).
|
||||
for bad in ("30", True):
|
||||
errors = step.validate({"id": "qa", "run": "echo hi", "timeout": bad})
|
||||
assert any("'timeout' must be a positive number" in e for e in errors)
|
||||
|
||||
def test_validate_rejects_non_finite_timeout(self):
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
|
||||
step = ShellStep()
|
||||
# inf/nan are floats and slip past a plain ``> 0`` check (``nan <= 0``
|
||||
# is False), but ``subprocess.run(timeout=...)`` would then fail at
|
||||
# runtime. YAML ``.inf``/``.nan`` scalars parse to these via safe_load.
|
||||
for bad in (float("inf"), float("-inf"), float("nan")):
|
||||
errors = step.validate({"id": "qa", "run": "echo hi", "timeout": bad})
|
||||
assert any("'timeout' must be a positive number" in e for e in errors)
|
||||
|
||||
def test_validate_accepts_positive_numeric_timeout(self):
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
|
||||
step = ShellStep()
|
||||
for good in (1, 300, 1800, 12.5):
|
||||
errors = step.validate({"id": "qa", "run": "echo hi", "timeout": good})
|
||||
assert not any("'timeout'" in e for e in errors)
|
||||
|
||||
class _StubStdin:
|
||||
"""Stdin stub exposing only a fixed ``isatty`` result.
|
||||
|
||||
@@ -2039,46 +1888,6 @@ class TestIfThenStep:
|
||||
errors = step.validate({"id": "test", "then": []})
|
||||
assert any("missing 'condition'" in e for e in errors)
|
||||
|
||||
@pytest.mark.parametrize("bad_else", [False, 0, "", {}, 42])
|
||||
def test_validate_rejects_non_list_else(self, bad_else):
|
||||
"""A non-list 'else' must be rejected even when it is falsy.
|
||||
|
||||
The original guard used ``if else_branch and ...`` which
|
||||
short-circuits for falsy non-list values (False/0/''/{}), letting a
|
||||
malformed else-branch pass validation only to be silently skipped at
|
||||
runtime. ``then`` is already strictly validated; ``else`` must match.
|
||||
"""
|
||||
from specify_cli.workflows.steps.if_then import IfThenStep
|
||||
|
||||
step = IfThenStep()
|
||||
errors = step.validate(
|
||||
{"id": "i", "condition": "true", "then": [], "else": bad_else}
|
||||
)
|
||||
assert any("'else' must be a list of steps" in e for e in errors)
|
||||
|
||||
@pytest.mark.parametrize("ok_else", [None, [], [{"id": "x", "command": "/y"}]])
|
||||
def test_validate_accepts_valid_else(self, ok_else):
|
||||
"""An explicit 'else' of None or a list stays valid.
|
||||
|
||||
``else`` is set explicitly here (including ``else: None``) so the
|
||||
explicit-None case is exercised, not just the missing-key case.
|
||||
"""
|
||||
from specify_cli.workflows.steps.if_then import IfThenStep
|
||||
|
||||
step = IfThenStep()
|
||||
errors = step.validate(
|
||||
{"id": "i", "condition": "true", "then": [], "else": ok_else}
|
||||
)
|
||||
assert not any("'else'" in e for e in errors)
|
||||
|
||||
def test_validate_accepts_missing_else(self):
|
||||
"""A missing 'else' key stays valid (no else branch)."""
|
||||
from specify_cli.workflows.steps.if_then import IfThenStep
|
||||
|
||||
step = IfThenStep()
|
||||
errors = step.validate({"id": "i", "condition": "true", "then": []})
|
||||
assert not any("'else'" in e for e in errors)
|
||||
|
||||
|
||||
class TestSwitchStep:
|
||||
"""Test the switch step type."""
|
||||
@@ -2143,35 +1952,6 @@ class TestSwitchStep:
|
||||
assert result.output["matched_case"] == "__default__"
|
||||
assert result.next_steps == []
|
||||
|
||||
def test_execute_non_dict_cases_fails_loudly(self):
|
||||
"""A non-mapping ``cases`` must fail the step, not crash the run.
|
||||
|
||||
``validate`` rejects a non-dict ``cases``, but the engine's
|
||||
``execute()`` does not auto-validate (see ``WorkflowEngine.load_workflow``
|
||||
docstring). Before the guard, ``execute`` called ``cases.items()`` on the
|
||||
raw value, so an unvalidated run with a list/scalar ``cases`` raised
|
||||
AttributeError and took down the whole run instead of failing this step.
|
||||
Mirrors the fan-out step's non-list ``items`` handling.
|
||||
"""
|
||||
from specify_cli.workflows.steps.switch import SwitchStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
step = SwitchStep()
|
||||
ctx = StepContext(steps={"review": {"output": {"choice": "approve"}}})
|
||||
for bad_cases in (["approve"], "approve", 5):
|
||||
result = step.execute(
|
||||
{
|
||||
"id": "route",
|
||||
"expression": "{{ steps.review.output.choice }}",
|
||||
"cases": bad_cases,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
assert result.status == StepStatus.FAILED
|
||||
assert "'cases' must be a mapping" in (result.error or "")
|
||||
# expression is still evaluated, so its value is surfaced for context.
|
||||
assert result.output["expression_value"] == "approve"
|
||||
|
||||
def test_validate_missing_expression(self):
|
||||
from specify_cli.workflows.steps.switch import SwitchStep
|
||||
|
||||
@@ -2914,101 +2694,6 @@ steps:
|
||||
errors = validate_workflow(definition)
|
||||
assert any("lowercase alphanumeric" in e for e in errors)
|
||||
|
||||
def test_non_string_workflow_id_reports_error(self):
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
workflow:
|
||||
id: 123
|
||||
name: "Test"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: step-one
|
||||
command: speckit.specify
|
||||
""")
|
||||
errors = validate_workflow(definition)
|
||||
assert any("workflow.id" in e and "string" in e for e in errors)
|
||||
|
||||
def test_non_string_name_reports_error(self):
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
workflow:
|
||||
id: "test"
|
||||
name: 123
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: step-one
|
||||
command: speckit.specify
|
||||
""")
|
||||
errors = validate_workflow(definition)
|
||||
assert any("workflow.name" in e and "string" in e for e in errors)
|
||||
|
||||
def test_unquoted_float_version_reports_error(self):
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
workflow:
|
||||
id: "test"
|
||||
name: "Test"
|
||||
version: 1.0
|
||||
steps:
|
||||
- id: step-one
|
||||
command: speckit.specify
|
||||
""")
|
||||
errors = validate_workflow(definition)
|
||||
assert any("workflow.version" in e and "quote" in e for e in errors)
|
||||
|
||||
def test_non_string_step_id_reports_error(self):
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
workflow:
|
||||
id: "test"
|
||||
name: "Test"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: 123
|
||||
command: speckit.specify
|
||||
""")
|
||||
errors = validate_workflow(definition)
|
||||
assert any("Step ID" in e and "string" in e for e in errors)
|
||||
|
||||
def test_falsey_non_string_scalars_report_typed_errors(self):
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
workflow:
|
||||
id: 0
|
||||
name: false
|
||||
version: 0.0
|
||||
steps:
|
||||
- id: 0
|
||||
command: speckit.specify
|
||||
""")
|
||||
errors = validate_workflow(definition)
|
||||
assert any("'workflow.id' must be a string" in e for e in errors)
|
||||
assert any("'workflow.name' must be a string" in e for e in errors)
|
||||
assert any("'workflow.version' must be a string" in e for e in errors)
|
||||
assert any("Step ID must be a string" in e for e in errors)
|
||||
assert not any("missing" in e for e in errors)
|
||||
|
||||
def test_unquoted_schema_version_accepted(self):
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: 1.0
|
||||
workflow:
|
||||
id: "test"
|
||||
name: "Test"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: step-one
|
||||
command: speckit.specify
|
||||
""")
|
||||
errors = validate_workflow(definition)
|
||||
assert errors == []
|
||||
|
||||
def test_no_steps(self):
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
@@ -3992,56 +3677,6 @@ steps:
|
||||
assert "retry-loop:tick:1" in state.step_results
|
||||
assert "retry-loop:tick:2" in state.step_results
|
||||
|
||||
def test_loop_with_bool_max_iterations_uses_default_cap(self, project_dir):
|
||||
"""A boolean max_iterations must fall back to the default cap of 10,
|
||||
not be treated as the int 1 (bool-is-int trap).
|
||||
|
||||
``max_iterations: true`` would otherwise slip past the int check
|
||||
(``isinstance(True, int)`` is True and ``True < 1`` is False) and
|
||||
cap the loop at ``range(True - 1) == range(0)`` — a single
|
||||
iteration. ``execute()`` does not auto-validate, so the engine's own
|
||||
guard is the only line of defence here.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
from specify_cli.workflows.base import RunStatus
|
||||
|
||||
import sys
|
||||
|
||||
counter_file = project_dir / ".counter"
|
||||
counter_file.write_text("0", encoding="utf-8")
|
||||
py = sys.executable
|
||||
script_file = project_dir / "_tick.py"
|
||||
script_file.write_text(
|
||||
f"import pathlib; p = pathlib.Path(r'{counter_file}')\n"
|
||||
"n = int(p.read_text()) + 1; p.write_text(str(n))\n"
|
||||
"print('pending', end='')\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
yaml_str = f"""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "while-bool-max-iterations"
|
||||
name: "While Bool Max Iterations"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: retry-loop
|
||||
type: while
|
||||
condition: "{{{{ 'done' not in steps.tick.output.stdout }}}}"
|
||||
max_iterations: true
|
||||
steps:
|
||||
- id: tick
|
||||
type: shell
|
||||
run: '"{py}" "{script_file}"'
|
||||
"""
|
||||
definition = WorkflowDefinition.from_string(yaml_str)
|
||||
engine = WorkflowEngine(project_dir)
|
||||
state = engine.execute(definition)
|
||||
|
||||
assert state.status == RunStatus.COMPLETED
|
||||
# Falls back to the default cap of 10, not range(True - 1) == 1 run.
|
||||
assert counter_file.read_text(encoding="utf-8").strip() == "10"
|
||||
|
||||
def test_do_while_loop_runs_to_max_when_condition_stays_true(self, project_dir):
|
||||
"""Do-while loop must still run to max_iterations when the condition
|
||||
never becomes false.
|
||||
@@ -4916,59 +4551,12 @@ class TestWorkflowRegistry:
|
||||
registry2 = WorkflowRegistry(project_dir)
|
||||
assert registry2.is_installed("test-wf")
|
||||
|
||||
@pytest.mark.parametrize("bad_content", ["[]", '{"schema_version": "1.0"}'])
|
||||
def test_load_tolerates_misshaped_registry(self, project_dir, bad_content):
|
||||
"""A JSON-valid but mis-shaped registry file must not crash every method.
|
||||
|
||||
A list root, or a dict lacking a 'workflows' mapping, previously made
|
||||
is_installed/get/list/remove/add raise TypeError/KeyError. Mirrors the
|
||||
shape guard StepRegistry._load already has.
|
||||
"""
|
||||
from specify_cli.workflows.catalog import WorkflowRegistry
|
||||
|
||||
reg_path = project_dir / ".specify" / "workflows" / "workflow-registry.json"
|
||||
reg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
reg_path.write_text(bad_content, encoding="utf-8")
|
||||
|
||||
registry = WorkflowRegistry(project_dir)
|
||||
assert registry.data == {
|
||||
"schema_version": WorkflowRegistry.SCHEMA_VERSION,
|
||||
"workflows": {},
|
||||
}
|
||||
# None of these should raise on the recovered-default shape.
|
||||
assert registry.is_installed("x") is False
|
||||
assert registry.get("x") is None
|
||||
assert registry.list() == {} # list() always returns a dict
|
||||
registry.remove("x")
|
||||
registry.add("x", {"name": "X"})
|
||||
assert registry.is_installed("x")
|
||||
|
||||
|
||||
# ===== Workflow Catalog Tests =====
|
||||
|
||||
class TestWorkflowCatalog:
|
||||
"""Test WorkflowCatalog catalog resolution."""
|
||||
|
||||
def test_search_with_non_string_fields(self, project_dir, monkeypatch):
|
||||
"""Non-string workflow fields (null/int name/description) must not
|
||||
raise TypeError in search — StepCatalog.search already coerces these."""
|
||||
from specify_cli.workflows.catalog import WorkflowCatalog
|
||||
|
||||
catalog = WorkflowCatalog(project_dir)
|
||||
monkeypatch.setattr(catalog, "_get_merged_workflows", lambda **kw: {
|
||||
"42": {
|
||||
"id": 42,
|
||||
"name": None,
|
||||
"description": 99,
|
||||
"_catalog_name": "test",
|
||||
"_install_allowed": True,
|
||||
},
|
||||
})
|
||||
|
||||
assert len(catalog.search()) == 1
|
||||
assert len(catalog.search(query="42")) == 1
|
||||
assert len(catalog.search(query="missing")) == 0
|
||||
|
||||
def test_default_catalogs(self, project_dir, monkeypatch):
|
||||
from specify_cli.workflows.catalog import WorkflowCatalog
|
||||
|
||||
@@ -7361,53 +6949,3 @@ steps:
|
||||
},
|
||||
)
|
||||
assert _gate_outcome(state) is None
|
||||
|
||||
|
||||
class TestWorkflowAddNonStringScalars:
|
||||
"""`workflow add` reports clean errors for non-string YAML scalars (#3420)."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field_yaml", "expected"),
|
||||
[
|
||||
('id: 123\n name: "Probe"\n version: "1.0.0"', "workflow.id"),
|
||||
('id: "probe"\n name: "Probe"\n version: 1.0', "workflow.version"),
|
||||
],
|
||||
)
|
||||
def test_add_reports_validation_error_not_traceback(
|
||||
self, project_dir, monkeypatch, field_yaml, expected
|
||||
):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
monkeypatch.chdir(project_dir)
|
||||
wf = project_dir / "workflow.yml"
|
||||
wf.write_text(
|
||||
"schema_version: \"1.0\"\n"
|
||||
f"workflow:\n {field_yaml}\n"
|
||||
"steps:\n - id: s1\n type: shell\n run: \"echo hi\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["workflow", "add", str(wf)])
|
||||
assert result.exit_code == 1
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
assert expected in result.output
|
||||
|
||||
def test_add_non_string_step_id_reports_validation_error(
|
||||
self, project_dir, monkeypatch
|
||||
):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
monkeypatch.chdir(project_dir)
|
||||
wf = project_dir / "workflow.yml"
|
||||
wf.write_text(
|
||||
"workflow:\n id: \"probe\"\n name: \"Probe\"\n version: \"1.0.0\"\n"
|
||||
"steps:\n - id: 123\n type: shell\n run: \"echo hi\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["workflow", "add", str(wf)])
|
||||
assert result.exit_code == 1
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
assert "Step ID" in result.output
|
||||
|
||||
@@ -96,21 +96,3 @@ def test_validate_remote_url_rejects_host_less_urls(url):
|
||||
def test_validate_remote_url_accepts_normal_https_url():
|
||||
# Sanity: a real host with a port still passes.
|
||||
adapters._validate_remote_url("team", "https://example.com:8080/c.json")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"https://[::1", # unclosed IPv6 bracket
|
||||
"https://[not-an-ip]/c.json",
|
||||
],
|
||||
)
|
||||
def test_validate_remote_url_rejects_malformed_url_cleanly(url):
|
||||
"""A malformed URL must raise BundlerError, not a raw ValueError.
|
||||
|
||||
``urlparse``/``hostname`` raise ``ValueError`` on a malformed authority
|
||||
(e.g. an unclosed IPv6 bracket). The validator's contract is to raise
|
||||
BundlerError for any bad URL, so the raw ValueError must not escape to the
|
||||
caller. Bundler sibling of #3369."""
|
||||
with pytest.raises(BundlerError):
|
||||
adapters._validate_remote_url("team", url)
|
||||
|
||||
@@ -112,14 +112,8 @@ Run a shell command and capture output:
|
||||
- id: run-tests
|
||||
type: shell
|
||||
run: "cd {{ inputs.project_dir }} && npm test"
|
||||
timeout: 1800 # Optional: max seconds before the command is killed (default 300)
|
||||
```
|
||||
|
||||
`timeout` is the maximum time in seconds the command may run before it is
|
||||
killed and the step fails; it must be a positive number and defaults to
|
||||
`300` (five minutes) when omitted. Raise it for long-running gates such as
|
||||
full builds, linter aggregators, or integration-test targets.
|
||||
|
||||
### Init Steps
|
||||
|
||||
Bootstrap a project the same way `specify init` does — scaffolding
|
||||
|
||||
Reference in New Issue
Block a user