mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e802a7dd52 | ||
|
|
126e56882b | ||
|
|
74d03a2814 | ||
|
|
14fa3ada08 | ||
|
|
1736f0746b | ||
|
|
c8ce488073 | ||
|
|
983a87f3e3 | ||
|
|
e3989e3572 | ||
|
|
34514fb20a | ||
|
|
87a9690cf9 | ||
|
|
b58ffba000 | ||
|
|
f537dfb2ac | ||
|
|
43ac4c158c | ||
|
|
d035a3f039 | ||
|
|
dbefc66acb | ||
|
|
3f7392ae32 | ||
|
|
d075b27360 | ||
|
|
eedf73f714 | ||
|
|
292eaa6c98 | ||
|
|
55da30c66d | ||
|
|
062418093d | ||
|
|
15ac745e8d | ||
|
|
8e2e2d2f25 | ||
|
|
a4d94309e0 | ||
|
|
8eadcd7624 | ||
|
|
892dd656f2 | ||
|
|
54ed736479 | ||
|
|
7b1065d857 | ||
|
|
c5fdae752b | ||
|
|
643f73a1d7 | ||
|
|
a7b439174f | ||
|
|
0da969df14 | ||
|
|
13d2cca154 | ||
|
|
ba1ce366b7 | ||
|
|
295eb221e3 | ||
|
|
5a901a698b | ||
|
|
94c7ec288f | ||
|
|
882e1e90d0 | ||
|
|
a307894709 | ||
|
|
10d4bca64c | ||
|
|
f1a8d8f95b |
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -5,4 +5,3 @@
|
||||
/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,4 +19,3 @@
|
||||
- [ ] 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@ded1f9488f68a970bc66ea5619e13e9b52e601cd # v23
|
||||
uses: DavidAnson/markdownlint-cli2-action@8de2aa07cae85fd17c0b35642db70cf5495f1d25 # v24.0.0
|
||||
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@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
|
||||
- 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@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
|
||||
- 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@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
|
||||
- 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@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
|
||||
@@ -26,4 +26,4 @@
|
||||
"ignores": [
|
||||
".genreleases/"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
12
.pre-commit-config.yaml
Normal file
12
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
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$
|
||||
75
CHANGELOG.md
75
CHANGELOG.md
@@ -2,6 +2,81 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [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
|
||||
|
||||
- fix(integrations): skip Windows Store python3 alias stub in resolve_python_interpreter (#3385)
|
||||
- fix(integrations): escape control characters in SKILL.md frontmatter (#3399)
|
||||
- fix(workflows): apply chained expression filters left-to-right (#3339)
|
||||
- fix(scripts): resolve invoke_separator by parse success, not python3 availability (#3304) (#3320)
|
||||
- fix(shared-infra): refresh_shared_templates preserves recovered user files (#3378)
|
||||
- fix(agents): resolve skill placeholders in Goose (yaml) command output (#3374)
|
||||
- fix(bundler): enforce version pin on bundled preset/extension installs (#3377)
|
||||
- Update Golden Demo extension to v0.3.0 (#3394)
|
||||
- test: isolate integration test home (#3144)
|
||||
- chore: release 0.12.8, begin 0.12.9.dev0 development (#3410)
|
||||
|
||||
## [0.12.8] - 2026-07-08
|
||||
|
||||
### Changed
|
||||
|
||||
- [extension] Add LLM Wiki extension to community catalog (#3361)
|
||||
- Docs: Document missing CLI flags and integrations (#3182)
|
||||
- Docs: Remove Cursor from CLI check list in README (#3184)
|
||||
- feat(extensions): port update-agent-context to Python (#3387)
|
||||
- fix(scripts): fall through to grep/sed when python3 is a broken stub in feature.json parser (#3312)
|
||||
- fix(toml): escape control characters so generated command files parse (#3341)
|
||||
- fix(cli): exit cleanly on malformed IPv6 URLs in `extension`/`preset`/`workflow add` (#3369)
|
||||
- fix(github-http): return None on malformed GHES port instead of raising (#3379)
|
||||
- fix(integrations): guard _sha256 against unreadable managed files (#3376)
|
||||
- chore: release 0.12.7, begin 0.12.8.dev0 development (#3398)
|
||||
|
||||
## [0.12.7] - 2026-07-07
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(bundler): bundle update uninstalls components dropped by new version (#3353)
|
||||
- fix(workflows): route run/resume errors to stderr under --json (#3352)
|
||||
- fix(workflows): fan-in validate() rejects non-mapping output (#3349)
|
||||
- fix(workflows): shell step validate() rejects non-string run (#3348)
|
||||
- fix(integrations): agy honors SPECKIT_INTEGRATION_AGY_EXTRA_ARGS (#3347)
|
||||
- Add Orchestration Task Context Management extension to community catalog (#3372)
|
||||
- Update DocGuard — CDD Enforcement extension to v0.30.0 (#3371)
|
||||
- Update Ripple extension to v1.1.0 (#3370)
|
||||
- feat(integrations): generalize post-processing to all format types (#3311)
|
||||
- chore: release 0.12.6, begin 0.12.7.dev0 development (#3393)
|
||||
|
||||
## [0.12.6] - 2026-07-07
|
||||
|
||||
### Changed
|
||||
|
||||
1
LICENSE
1
LICENSE
@@ -19,4 +19,3 @@ 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.
|
||||
|
||||
|
||||
@@ -406,7 +406,7 @@ specify init . --force --integration copilot
|
||||
specify init --here --force --integration copilot
|
||||
```
|
||||
|
||||
The CLI will check that your selected agent's CLI tool is installed (for integrations that require a CLI), such as Claude Code, Gemini CLI, Qwen Code, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi Coding Agent, Oh My Pi, Forge, Goose, Mistral Vibe, or ZCode. If you don't have the required tool installed, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
|
||||
The CLI checks that the selected integration's required CLI tool is installed on your machine when that integration has `requires_cli: True`. If you do not have the required tool installed, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
|
||||
|
||||
```bash
|
||||
specify init <project_name> --integration copilot --ignore-agent-tools
|
||||
|
||||
1
docs/.gitignore
vendored
1
docs/.gitignore
vendored
@@ -6,4 +6,3 @@ obj/
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.log
|
||||
|
||||
|
||||
@@ -52,13 +52,14 @@ 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) |
|
||||
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
|
||||
| GitHub Issues Integration 1 | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) |
|
||||
| GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) |
|
||||
| Golden Demo | Extracts acceptance criteria from specs, builds test vectors, and produces a behavioral drift report — complementary to Architecture Guard and CDD | `docs` | Read+Write | [spec-kit-golden-demo](https://github.com/jasstt/spec-kit-golden-demo) |
|
||||
| Golden Demo | Deterministic behavioral drift oracle. Extracts acceptance criteria, generates fuzz test vectors (seed=42), compares golden Python implementations against real code in any language. CI/CD gatekeeper with warn/strict modes. | `docs` | Read+Write | [spec-kit-golden-demo](https://github.com/jasstt/spec-kit-golden-demo) |
|
||||
| Improve Extension | Audits any codebase as a senior advisor and writes prioritized, self-contained spec prompts under specs/ that the spec-kit lifecycle can process | `process` | Read+Write | [spec-kit-improve](https://github.com/d0whc3r/spec-kit-improve) |
|
||||
| Intake | Normalize PRD, design, HTML SSOT, and test-case evidence into SDD-ready intake artifacts. | `docs` | Read+Write | [spec-kit-intake](https://github.com/bigsmartben/spec-kit-intake) |
|
||||
| Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) |
|
||||
@@ -67,6 +68,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Jira Integration (Sync Engine) | Idempotent, drift-aware, fail-closed reconcile engine mirroring spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase) | `integration` | Read+Write | [spec-kit-jira-sync](https://github.com/ashbrener/spec-kit-jira-sync) |
|
||||
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
|
||||
| Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear-sync](https://github.com/ashbrener/spec-kit-linear-sync) |
|
||||
| LLM Wiki | LLM-maintained compounding project wiki: source ingestion, cited answers, and consistency linting | `docs` | Read+Write | [spec-kit-wiki](https://github.com/formin/spec-kit-wiki) |
|
||||
| Loop Engineering | Engineer safe autonomous agent loops for spec-driven development: a maker/checker split, externalized loop state, and stay-the-engineer guardrails against comprehension debt and cognitive surrender | `process` | Read+Write | [spec-kit-loop](https://github.com/formin/spec-kit-loop) |
|
||||
| MAQA — Multi-Agent & Quality Assurance | Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) |
|
||||
| MAQA Azure DevOps Integration | Azure DevOps Boards integration for MAQA — syncs User Stories and Task children as features progress | `integration` | Read+Write | [spec-kit-maqa-azure-devops](https://github.com/GenieRobot/spec-kit-maqa-azure-devops) |
|
||||
@@ -116,6 +118,7 @@ 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) |
|
||||
|
||||
@@ -14,3 +14,5 @@ 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.
|
||||
|
||||
@@ -26,7 +26,7 @@ specify extension add <name>
|
||||
| --------------- | -------------------------------------------------------- |
|
||||
| `--dev` | Install from a local directory (for development) |
|
||||
| `--from <url>` | Install from a custom URL instead of the catalog |
|
||||
| `--force` | Overwrite if already installed |
|
||||
| `--force` | Overwrite if the extension is already installed |
|
||||
| `--priority <N>`| Resolution priority (default: 10; lower = higher precedence) |
|
||||
|
||||
Installs an extension from the catalog, a URL, or a local directory. Extension commands are automatically registered with the currently installed AI coding agent integration.
|
||||
|
||||
@@ -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 (`specs/<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 (any `plan.md` under `specs/`, including nested scoped layouts such as `specs/<scope>/<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`.
|
||||
When `plan_path` is omitted, the script auto-detects the most recently modified `specs/**/plan.md` (searched recursively, so nested scoped layouts are discovered).
|
||||
|
||||
@@ -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,16 +307,28 @@ import sys
|
||||
from pathlib import Path
|
||||
root = Path(sys.argv[1]).resolve()
|
||||
specs = root / "specs"
|
||||
plans = sorted(
|
||||
specs.glob("*/plan.md"),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
if plans:
|
||||
|
||||
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").
|
||||
try:
|
||||
print(plans[0].relative_to(root).as_posix())
|
||||
except ValueError:
|
||||
print("")
|
||||
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())
|
||||
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,9 +426,11 @@ if (-not $PlanPath) {
|
||||
if (-not $PlanPath) {
|
||||
try {
|
||||
$specsDir = Join-Path $ProjectRoot 'specs'
|
||||
$candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } |
|
||||
Where-Object { $_ } |
|
||||
# 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 |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
if ($candidate) {
|
||||
|
||||
352
extensions/agent-context/scripts/python/update_agent_context.py
Normal file
352
extensions/agent-context/scripts/python/update_agent_context.py
Normal file
@@ -0,0 +1,352 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Refresh the managed Spec Kit section in the coding agent's context file(s).
|
||||
|
||||
Python port of ``update-agent-context.sh`` / ``update-agent-context.ps1``.
|
||||
|
||||
Reads ``context_files`` or ``context_file``, plus ``context_markers.{start,end}``,
|
||||
from the agent-context extension config:
|
||||
.specify/extensions/agent-context/agent-context-config.yml
|
||||
|
||||
Usage: update_agent_context.py [plan_path]
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_START = "<!-- SPECKIT START -->"
|
||||
DEFAULT_END = "<!-- SPECKIT END -->"
|
||||
|
||||
|
||||
def _err(message: str) -> None:
|
||||
print(message, file=sys.stderr)
|
||||
|
||||
|
||||
def _get_str(obj: object, *keys: str) -> str:
|
||||
node = obj
|
||||
for key in keys:
|
||||
if isinstance(node, dict) and key in node:
|
||||
node = node[key]
|
||||
else:
|
||||
return ""
|
||||
return node if isinstance(node, str) else ""
|
||||
|
||||
|
||||
def _collect_context_files(data: dict, project_root: str) -> list[str]:
|
||||
"""Resolve the managed context files from config, mirroring the bash logic."""
|
||||
context_files: list[str] = []
|
||||
seen: set[str] = set()
|
||||
case_insensitive = sys.platform.startswith(("win32", "cygwin", "msys"))
|
||||
|
||||
def add(value: object) -> None:
|
||||
if not isinstance(value, str):
|
||||
return
|
||||
candidate = value.strip()
|
||||
if not candidate:
|
||||
return
|
||||
key = candidate.casefold() if case_insensitive else candidate
|
||||
if key in seen:
|
||||
return
|
||||
context_files.append(candidate)
|
||||
seen.add(key)
|
||||
|
||||
raw_files = data.get("context_files")
|
||||
if isinstance(raw_files, list):
|
||||
for value in raw_files:
|
||||
add(value)
|
||||
if not context_files:
|
||||
add(_get_str(data, "context_file"))
|
||||
if not context_files:
|
||||
# Self-seed: when the config declares no target, derive one from the
|
||||
# active integration recorded in init-options.json, mapped through the
|
||||
# bundled agent-context-defaults.json file. Independent of the Specify
|
||||
# CLI by design.
|
||||
integration_key = ""
|
||||
try:
|
||||
with open(
|
||||
f"{project_root}/.specify/init-options.json", "r", encoding="utf-8"
|
||||
) as fh:
|
||||
opts = json.load(fh)
|
||||
if isinstance(opts, dict):
|
||||
value = opts.get("integration") or opts.get("ai") or ""
|
||||
integration_key = value if isinstance(value, str) else ""
|
||||
except Exception:
|
||||
integration_key = ""
|
||||
if integration_key:
|
||||
defaults_path = (
|
||||
f"{project_root}/.specify/extensions/agent-context/"
|
||||
"agent-context-defaults.json"
|
||||
)
|
||||
mapping = {}
|
||||
try:
|
||||
with open(defaults_path, "r", encoding="utf-8") as fh:
|
||||
loaded = json.load(fh)
|
||||
agents = loaded.get("agents", {}) if isinstance(loaded, dict) else {}
|
||||
mapping = agents if isinstance(agents, dict) else {}
|
||||
except Exception:
|
||||
_err(
|
||||
"agent-context: unable to read %s; cannot self-seed the context "
|
||||
"file. Set context_file in the extension config." % defaults_path
|
||||
)
|
||||
mapping = {}
|
||||
add(mapping.get(integration_key, "") or "")
|
||||
if not context_files:
|
||||
_err(
|
||||
"agent-context: no default context file is known for integration "
|
||||
"%s. Set context_file in the extension config to choose one."
|
||||
% integration_key
|
||||
)
|
||||
return context_files
|
||||
|
||||
|
||||
def _validate_context_file(project_root: str, context_file: str) -> str | None:
|
||||
"""Return an error message when the path escapes the project root."""
|
||||
if context_file.startswith("/") or re.match(r"^[A-Za-z]:", context_file):
|
||||
return (
|
||||
"agent-context: context files must be project-relative paths; "
|
||||
f"got '{context_file}'."
|
||||
)
|
||||
if "\\" in context_file:
|
||||
return (
|
||||
"agent-context: context files must not contain backslash separators; "
|
||||
f"got '{context_file}'."
|
||||
)
|
||||
if ".." in context_file.split("/"):
|
||||
return (
|
||||
"agent-context: context files must not contain '..' path segments; "
|
||||
f"got '{context_file}'."
|
||||
)
|
||||
root = Path(project_root).resolve()
|
||||
target = (root / context_file).resolve()
|
||||
try:
|
||||
target.relative_to(root)
|
||||
except ValueError:
|
||||
return (
|
||||
"agent-context: context file path resolves outside the project root; "
|
||||
f"got '{context_file}'."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_plan_path(project_root: str) -> str:
|
||||
"""Derive the plan path: feature.json first, then the mtime fallback."""
|
||||
plan_path = ""
|
||||
feature_json = Path(project_root) / ".specify" / "feature.json"
|
||||
if feature_json.is_file():
|
||||
feature_dir = ""
|
||||
try:
|
||||
with open(feature_json, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
value = data.get("feature_directory", "")
|
||||
feature_dir = value if isinstance(value, str) else ""
|
||||
except Exception:
|
||||
feature_dir = ""
|
||||
# Normalize backslashes (written by PS on Windows) before path ops.
|
||||
feature_dir = feature_dir.replace("\\", "/").rstrip("/")
|
||||
if feature_dir:
|
||||
# feature_directory may be relative or absolute (absolute paths
|
||||
# outside the project root are preserved as-is), including
|
||||
# drive-qualified paths (C:/...) written by PowerShell on Windows.
|
||||
if feature_dir.startswith("/") or re.match(r"^[A-Za-z]:/", feature_dir):
|
||||
candidate = Path(feature_dir) / "plan.md"
|
||||
else:
|
||||
candidate = Path(project_root) / feature_dir / "plan.md"
|
||||
if candidate.is_file():
|
||||
# Resolve symlinks before comparing so paths like /var/… vs
|
||||
# /private/var/… (macOS) are treated as equivalent.
|
||||
root = Path(project_root).resolve()
|
||||
resolved = candidate.resolve()
|
||||
try:
|
||||
plan_path = resolved.relative_to(root).as_posix()
|
||||
except ValueError:
|
||||
plan_path = resolved.as_posix()
|
||||
|
||||
if not plan_path:
|
||||
root = Path(project_root).resolve()
|
||||
plans = sorted(
|
||||
(root / "specs").glob("*/plan.md"),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
if plans:
|
||||
try:
|
||||
plan_path = plans[0].relative_to(root).as_posix()
|
||||
except ValueError:
|
||||
plan_path = ""
|
||||
return plan_path
|
||||
|
||||
|
||||
def _build_section(marker_start: str, marker_end: str, plan_path: str) -> str:
|
||||
lines = [
|
||||
marker_start,
|
||||
"For additional context about technologies to be used, project structure,",
|
||||
"shell commands, and other important information, read the current plan",
|
||||
]
|
||||
if plan_path:
|
||||
lines.append(f"at {plan_path}")
|
||||
lines.append(marker_end)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def ensure_mdc_frontmatter(content: str) -> str:
|
||||
"""Ensure ``.mdc`` content has YAML frontmatter with ``alwaysApply: true``.
|
||||
|
||||
Cursor only auto-loads ``.mdc`` rule files that carry frontmatter with
|
||||
``alwaysApply: true``. Prepend it when missing, or repair the value while
|
||||
preserving any existing frontmatter comments/formatting.
|
||||
"""
|
||||
leading_ws = len(content) - len(content.lstrip())
|
||||
leading = content[:leading_ws]
|
||||
stripped = content[leading_ws:]
|
||||
|
||||
if not stripped.startswith("---"):
|
||||
return "---\nalwaysApply: true\n---\n\n" + content
|
||||
|
||||
match = re.match(
|
||||
r"^(---[ \t]*\r?\n)(.*?)(\r?\n---[ \t]*)(\r?\n|$)(.*)",
|
||||
stripped,
|
||||
re.DOTALL,
|
||||
)
|
||||
if not match:
|
||||
return "---\nalwaysApply: true\n---\n\n" + content
|
||||
|
||||
opening, fm_text, closing, sep, rest = match.groups()
|
||||
newline = "\r\n" if "\r\n" in opening else "\n"
|
||||
|
||||
if re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:[ \t]*true[ \t]*(?:#.*)?$", fm_text):
|
||||
return content
|
||||
|
||||
if re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:", fm_text):
|
||||
fm_text = re.sub(
|
||||
r"(?m)^([ \t]*)alwaysApply[ \t]*:.*?([ \t]*(?:#.*)?)$",
|
||||
r"\1alwaysApply: true\2",
|
||||
fm_text,
|
||||
count=1,
|
||||
)
|
||||
elif fm_text.strip():
|
||||
fm_text = fm_text + newline + "alwaysApply: true"
|
||||
else:
|
||||
fm_text = "alwaysApply: true"
|
||||
|
||||
return f"{leading}{opening}{fm_text}{closing}{sep}{rest}"
|
||||
|
||||
|
||||
def _upsert_section(
|
||||
ctx_path: str, marker_start: str, marker_end: str, section: str
|
||||
) -> None:
|
||||
"""Insert or replace the managed section, then normalize and write."""
|
||||
if os.path.exists(ctx_path):
|
||||
with open(ctx_path, "r", encoding="utf-8-sig") as fh:
|
||||
content = fh.read()
|
||||
s = content.find(marker_start)
|
||||
e = content.find(marker_end, s if s != -1 else 0)
|
||||
if s != -1 and e != -1 and e > s:
|
||||
end_of_marker = e + len(marker_end)
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\r":
|
||||
end_of_marker += 1
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\n":
|
||||
end_of_marker += 1
|
||||
new_content = content[:s] + section + content[end_of_marker:]
|
||||
elif s != -1:
|
||||
new_content = content[:s] + section
|
||||
elif e != -1:
|
||||
end_of_marker = e + len(marker_end)
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\r":
|
||||
end_of_marker += 1
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\n":
|
||||
end_of_marker += 1
|
||||
new_content = section + content[end_of_marker:]
|
||||
else:
|
||||
if content and not content.endswith("\n"):
|
||||
content += "\n"
|
||||
new_content = (content + "\n" + section) if content else section
|
||||
else:
|
||||
new_content = section
|
||||
|
||||
new_content = new_content.replace("\r\n", "\n").replace("\r", "\n")
|
||||
if ctx_path.casefold().endswith(".mdc"):
|
||||
new_content = ensure_mdc_frontmatter(new_content)
|
||||
with open(ctx_path, "wb") as fh:
|
||||
fh.write(new_content.encode("utf-8"))
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = sys.argv[1:] if argv is None else argv
|
||||
project_root = os.getcwd()
|
||||
ext_config = (
|
||||
f"{project_root}/.specify/extensions/agent-context/agent-context-config.yml"
|
||||
)
|
||||
|
||||
if not os.path.isfile(ext_config):
|
||||
_err(f"agent-context: {ext_config} not found; nothing to do.")
|
||||
return 0
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
_err(
|
||||
"agent-context: PyYAML is required to parse extension config but is "
|
||||
"not available in the current Python environment.\n"
|
||||
" To resolve: pip install pyyaml (or install it into the environment "
|
||||
"used by python3).\n"
|
||||
" Context file will not be updated until PyYAML is importable."
|
||||
)
|
||||
_err("agent-context: skipping update (see above for details).")
|
||||
return 0
|
||||
|
||||
try:
|
||||
with open(ext_config, "r", encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
except Exception as exc:
|
||||
_err(
|
||||
f"agent-context: unable to parse {ext_config} ({exc}); "
|
||||
"cannot update context."
|
||||
)
|
||||
_err("agent-context: skipping update (see above for details).")
|
||||
return 0
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
|
||||
context_files = _collect_context_files(data, project_root)
|
||||
if not context_files:
|
||||
_err(
|
||||
"agent-context: context_files/context_file not set in extension config; "
|
||||
"nothing to do."
|
||||
)
|
||||
return 0
|
||||
|
||||
for context_file in context_files:
|
||||
error = _validate_context_file(project_root, context_file)
|
||||
if error:
|
||||
_err(error)
|
||||
return 1
|
||||
|
||||
marker_start = _get_str(data, "context_markers", "start") or DEFAULT_START
|
||||
marker_end = _get_str(data, "context_markers", "end") or DEFAULT_END
|
||||
|
||||
plan_path = args[0] if args else ""
|
||||
if not plan_path:
|
||||
plan_path = _resolve_plan_path(project_root)
|
||||
|
||||
section = _build_section(marker_start, marker_end, plan_path)
|
||||
|
||||
for context_file in context_files:
|
||||
ctx_path = os.path.join(project_root, context_file)
|
||||
os.makedirs(os.path.dirname(ctx_path) or ".", exist_ok=True)
|
||||
_upsert_section(ctx_path, marker_start, marker_end, section)
|
||||
print(f"agent-context: updated {context_file}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-06T00:00:00Z",
|
||||
"updated_at": "2026-07-08T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -1180,6 +1180,39 @@
|
||||
"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",
|
||||
@@ -1213,6 +1246,47 @@
|
||||
"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",
|
||||
@@ -1398,10 +1472,10 @@
|
||||
"golden-demo": {
|
||||
"name": "Golden Demo",
|
||||
"id": "golden-demo",
|
||||
"description": "Extracts acceptance criteria from specs, builds test vectors, and produces a behavioral drift report — complementary to Architecture Guard and CDD.",
|
||||
"description": "Deterministic behavioral drift oracle. Extracts acceptance criteria, generates fuzz test vectors (seed=42), compares golden Python implementations against real code in any language. CI/CD gatekeeper with warn/strict modes.",
|
||||
"author": "jasstt",
|
||||
"version": "0.1.1",
|
||||
"download_url": "https://github.com/jasstt/spec-kit-golden-demo/archive/refs/tags/v0.1.1.zip",
|
||||
"version": "0.3.0",
|
||||
"download_url": "https://github.com/jasstt/spec-kit-golden-demo/archive/refs/tags/v0.3.0.zip",
|
||||
"repository": "https://github.com/jasstt/spec-kit-golden-demo",
|
||||
"homepage": "https://github.com/jasstt/spec-kit-golden-demo",
|
||||
"documentation": "https://github.com/jasstt/spec-kit-golden-demo",
|
||||
@@ -1412,13 +1486,16 @@
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 2,
|
||||
"commands": 3,
|
||||
"hooks": 2
|
||||
},
|
||||
"tags": [
|
||||
"testing",
|
||||
"drift-detection",
|
||||
"behavioral-oracle",
|
||||
"fuzzing",
|
||||
"ci-cd",
|
||||
"cross-language",
|
||||
"tdd",
|
||||
"quality"
|
||||
],
|
||||
@@ -1426,7 +1503,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-06-24T00:00:00Z",
|
||||
"updated_at": "2026-06-24T00:00:00Z"
|
||||
"updated_at": "2026-07-07T00:00:00Z"
|
||||
},
|
||||
"harness": {
|
||||
"name": "Research Harness",
|
||||
@@ -4375,6 +4452,40 @@
|
||||
"created_at": "2026-04-13T00:00:00Z",
|
||||
"updated_at": "2026-04-13T00:00:00Z"
|
||||
},
|
||||
"wiki": {
|
||||
"name": "LLM Wiki",
|
||||
"id": "wiki",
|
||||
"description": "LLM-maintained compounding project wiki: source ingestion, cited answers, and consistency linting",
|
||||
"author": "formin",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/formin/spec-kit-wiki/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/formin/spec-kit-wiki",
|
||||
"homepage": "https://github.com/formin/spec-kit-wiki",
|
||||
"documentation": "https://github.com/formin/spec-kit-wiki/blob/main/README.md",
|
||||
"changelog": "https://github.com/formin/spec-kit-wiki/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "docs",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.2.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 5,
|
||||
"hooks": 2
|
||||
},
|
||||
"tags": [
|
||||
"wiki",
|
||||
"knowledge-base",
|
||||
"docs",
|
||||
"memory",
|
||||
"context-management"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-06T00:00:00Z",
|
||||
"updated_at": "2026-07-06T00:00:00Z"
|
||||
},
|
||||
"wireframe": {
|
||||
"name": "Wireframe Visual Feedback Loop",
|
||||
"id": "wireframe",
|
||||
|
||||
@@ -48,4 +48,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,7 +446,10 @@ if ($env:GIT_BRANCH_NAME) {
|
||||
$branchSuffix = Get-BranchName -Description $featureDesc
|
||||
}
|
||||
|
||||
if ($Timestamp -and $Number -ne 0) {
|
||||
# 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')) {
|
||||
Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
|
||||
$Number = 0
|
||||
}
|
||||
@@ -456,7 +459,10 @@ if ($env:GIT_BRANCH_NAME) {
|
||||
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
|
||||
} else {
|
||||
$branchScopePrefix = Get-BranchScopePrefix -Template $branchTemplate -BranchSuffix $branchSuffix
|
||||
if ($Number -eq 0) {
|
||||
# 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 ($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,5 +76,3 @@ 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)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.12.7.dev0"
|
||||
version = "0.12.11"
|
||||
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,4 +83,3 @@ 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
|
||||
|
||||
@@ -97,17 +97,26 @@ read_feature_json_feature_directory() {
|
||||
local fj="$repo_root/.specify/feature.json"
|
||||
[[ -f "$fj" ]] || { printf '%s' ''; return 0; }
|
||||
|
||||
# Try parsers in order (jq -> python3 -> grep/sed), falling through on
|
||||
# failure. Selection is by *parse success*, not mere availability: on
|
||||
# Windows `python3` commonly resolves to the Microsoft Store App Execution
|
||||
# Alias stub, which passes `command -v` but fails at runtime (exit 49), so
|
||||
# an availability-gated `elif` would pick python3, swallow its failure, and
|
||||
# never reach the grep/sed fallback -- leaving feature.json unreadable even
|
||||
# though it is valid (issue #3304).
|
||||
local _fd=''
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then
|
||||
_fd=''
|
||||
fi
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
fi
|
||||
if [[ -z "$_fd" ]] && command -v python3 >/dev/null 2>&1; then
|
||||
# Use Python so pretty-printed/multi-line JSON still parses correctly.
|
||||
if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then
|
||||
_fd=''
|
||||
fi
|
||||
else
|
||||
fi
|
||||
if [[ -z "$_fd" ]]; then
|
||||
# Last-resort single-line grep/sed fallback. The `|| true` guards against
|
||||
# grep returning 1 (no match) aborting under `set -e` / `pipefail`.
|
||||
_fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \
|
||||
@@ -235,21 +244,29 @@ get_invoke_separator() {
|
||||
|
||||
local integration_json="$repo_root/.specify/integration.json"
|
||||
local separator="."
|
||||
local parsed_with_jq=0
|
||||
local parsed=0
|
||||
|
||||
if [[ -f "$integration_json" ]]; then
|
||||
# Try parsers in order (jq -> python3 -> awk), falling through on
|
||||
# failure. Selection is by *parse success*, not mere availability: on
|
||||
# Windows `python3` commonly resolves to the Microsoft Store App
|
||||
# Execution Alias stub, which passes `command -v` but fails at runtime
|
||||
# (exit 49). An availability-gated branch would pick python3, swallow
|
||||
# its failure, and — because this function historically had no text
|
||||
# fallback — silently return "." even for `-`-separator integrations
|
||||
# (e.g. forge, cline), yielding wrong command hints (issue #3304).
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
local jq_separator
|
||||
if jq_separator=$(jq -r '(.default_integration // .integration // "") as $k | if $k == "" then "." else (.integration_settings[$k].invoke_separator // ".") end' "$integration_json" 2>/dev/null); then
|
||||
parsed_with_jq=1
|
||||
case "$jq_separator" in
|
||||
"."|"-") separator="$jq_separator" ;;
|
||||
"."|"-") separator="$jq_separator"; parsed=1 ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$parsed_with_jq" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then
|
||||
if separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null
|
||||
if [[ "$parsed" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then
|
||||
local py_separator
|
||||
if py_separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null
|
||||
import json
|
||||
import sys
|
||||
|
||||
@@ -265,17 +282,64 @@ try:
|
||||
separator = entry["invoke_separator"]
|
||||
print(separator)
|
||||
except Exception:
|
||||
print(".")
|
||||
sys.exit(1)
|
||||
PY
|
||||
); then
|
||||
case "$separator" in
|
||||
"."|"-") ;;
|
||||
*) separator="." ;;
|
||||
case "$py_separator" in
|
||||
"."|"-") separator="$py_separator"; parsed=1 ;;
|
||||
esac
|
||||
else
|
||||
separator="."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$parsed" -eq 0 ]]; then
|
||||
# Last-resort text fallback for environments with neither jq nor a
|
||||
# working python3 (e.g. stock Windows + Git Bash). Reads the active
|
||||
# integration key (default_integration, else integration) and its
|
||||
# invoke_separator from within the integration_settings object.
|
||||
# Handles both pretty-printed (the written form) and compact JSON.
|
||||
# Accumulate all lines into one buffer in END rather than using
|
||||
# gawk-only whole-file slurp (RS="^$"), so this stays portable to
|
||||
# the BSD awk on macOS.
|
||||
local awk_separator
|
||||
awk_separator=$(awk '
|
||||
function keyval(d, name, v) {
|
||||
if (match(d, "\"" name "\"[ \t\r\n]*:[ \t\r\n]*\"[^\"]*\"")) {
|
||||
v=substr(d,RSTART,RLENGTH); sub(/^.*:[ \t\r\n]*"/,"",v); sub(/"$/,"",v); return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
{ doc = doc $0 "\n" }
|
||||
END {
|
||||
key=keyval(doc,"default_integration"); if (key=="") key=keyval(doc,"integration")
|
||||
sep="."
|
||||
if (key!="") {
|
||||
settings=doc
|
||||
if (match(doc, /"integration_settings"[ \t\r\n]*:[ \t\r\n]*[{]/)) {
|
||||
settings=substr(doc, RSTART+RLENGTH-1)
|
||||
}
|
||||
if (match(settings, "\"" key "\"[ \t\r\n]*:[ \t\r\n]*[{]")) {
|
||||
start=RSTART+RLENGTH-1
|
||||
depth=0
|
||||
obj=""
|
||||
for (i=start; i<=length(settings); i++) {
|
||||
c=substr(settings,i,1)
|
||||
obj=obj c
|
||||
if (c=="{") depth++
|
||||
else if (c=="}") { depth--; if (depth==0) break }
|
||||
}
|
||||
if (match(obj, /"invoke_separator"[ \t\r\n]*:[ \t\r\n]*"[-.]"/)) {
|
||||
tok=substr(obj,RSTART,RLENGTH); s=substr(tok,length(tok)-1,1)
|
||||
if (s=="." || s=="-") sep=s
|
||||
}
|
||||
}
|
||||
}
|
||||
print sep
|
||||
}
|
||||
' "$integration_json" 2>/dev/null)
|
||||
case "$awk_separator" in
|
||||
"."|"-") separator="$awk_separator" ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
|
||||
_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root"
|
||||
|
||||
@@ -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,8 +77,7 @@ 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,8 +141,9 @@ 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"`` and ``powershell`` when it is
|
||||
``"ps"``. Tracks all installed files in ``speckit.manifest.json``.
|
||||
``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``.
|
||||
|
||||
Shared scripts and page templates are processed to resolve
|
||||
``__SPECKIT_COMMAND_<NAME>__`` placeholders using *invoke_separator*
|
||||
|
||||
@@ -127,7 +127,14 @@ def resolve_github_release_asset_api_url(
|
||||
if hostname == "github.com":
|
||||
api_base = "https://api.github.com"
|
||||
elif is_ghes:
|
||||
authority = hostname if parsed.port is None else f"{hostname}:{parsed.port}"
|
||||
# ``parsed.port`` raises ValueError on a malformed port (e.g.
|
||||
# ``host:notaport``); the function's contract is to return None for
|
||||
# anything it can't resolve, not to raise.
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
return None
|
||||
authority = hostname if port is None else f"{hostname}:{port}"
|
||||
api_base = f"{parsed.scheme}://{authority}/api/v3"
|
||||
else:
|
||||
return None
|
||||
|
||||
60
src/specify_cli/_toml_string.py
Normal file
60
src/specify_cli/_toml_string.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Shared TOML string-escaping helpers.
|
||||
|
||||
Both TOML command renderers — ``TomlIntegration`` (gemini, tabnine) in
|
||||
``specify_cli.integrations.base`` and ``CommandRegistrar.render_toml_command``
|
||||
(extension/preset commands) in ``specify_cli.agents`` — need the same rules for
|
||||
detecting characters TOML forbids literally and for emitting a fully-escaped
|
||||
basic string. Keeping one implementation here avoids the two drifting apart if
|
||||
the escaping rules change again.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def has_illegal_toml_control(value: str) -> bool:
|
||||
"""True when *value* contains a character TOML forbids literally.
|
||||
|
||||
TOML basic/literal strings (single- or multi-line) allow tab and, in the
|
||||
multiline forms, newlines — but every other control character
|
||||
(``U+0000``–``U+001F`` and ``U+007F``) must be ``\\u``-escaped, which only a
|
||||
basic string can do. A bare carriage return counts too: a multiline basic
|
||||
string treats ``\\r`` as a newline only when paired into ``\\r\\n``; a lone
|
||||
``\\r`` is an illegal control character.
|
||||
"""
|
||||
length = len(value)
|
||||
for i, ch in enumerate(value):
|
||||
code = ord(ch)
|
||||
if ch == "\r":
|
||||
# Only a CR that is part of a CRLF newline is allowed literally.
|
||||
if i + 1 < length and value[i + 1] == "\n":
|
||||
continue
|
||||
return True
|
||||
if (code < 0x20 and ch not in ("\t", "\n")) or code == 0x7F:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def escape_toml_basic(value: str) -> str:
|
||||
"""Render *value* as a single-line basic string, escaping everything.
|
||||
|
||||
Always valid TOML: backslash/quote are escaped, the common control chars
|
||||
use their short escapes, and any remaining control character is emitted as
|
||||
a ``\\uXXXX`` sequence.
|
||||
"""
|
||||
out: list[str] = []
|
||||
for ch in value:
|
||||
code = ord(ch)
|
||||
if ch == "\\":
|
||||
out.append("\\\\")
|
||||
elif ch == '"':
|
||||
out.append('\\"')
|
||||
elif ch == "\n":
|
||||
out.append("\\n")
|
||||
elif ch == "\r":
|
||||
out.append("\\r")
|
||||
elif ch == "\t":
|
||||
out.append("\\t")
|
||||
elif code < 0x20 or code == 0x7F:
|
||||
out.append(f"\\u{code:04x}")
|
||||
else:
|
||||
out.append(ch)
|
||||
return '"' + "".join(out) + '"'
|
||||
@@ -16,6 +16,8 @@ from typing import Any, Dict, List, Optional
|
||||
import yaml
|
||||
|
||||
from ._init_options import is_ai_skills_enabled, load_init_options
|
||||
from ._toml_string import escape_toml_basic as _escape_toml_basic
|
||||
from ._toml_string import has_illegal_toml_control as _has_illegal_toml_control
|
||||
from ._utils import relative_extension_path_violation
|
||||
|
||||
|
||||
@@ -258,7 +260,12 @@ class CommandRegistrar:
|
||||
# ``C:\\Users\\...`` whose ``\\U`` reads as an invalid unicode escape) would
|
||||
# produce unparseable TOML — route those to the *literal* form ('''...'''),
|
||||
# which does not process escapes, or to the escaped basic string.
|
||||
if '"""' not in body and "\\" not in body:
|
||||
# Control characters (U+0000–U+001F except tab/newline, U+007F) and a bare
|
||||
# CR are illegal in every TOML string form, so a body containing them must
|
||||
# go to the escaped basic string regardless of which delimiters it uses.
|
||||
if self._has_illegal_toml_control(body):
|
||||
toml_lines.append(f"prompt = {self._render_basic_toml_string(body)}")
|
||||
elif '"""' not in body and "\\" not in body:
|
||||
toml_lines.append('prompt = """')
|
||||
toml_lines.append(body)
|
||||
toml_lines.append('"""')
|
||||
@@ -271,17 +278,11 @@ class CommandRegistrar:
|
||||
|
||||
return "\n".join(toml_lines)
|
||||
|
||||
@staticmethod
|
||||
def _render_basic_toml_string(value: str) -> str:
|
||||
"""Render *value* as a TOML basic string literal."""
|
||||
escaped = (
|
||||
value.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t")
|
||||
)
|
||||
return f'"{escaped}"'
|
||||
# Control-char detection and basic-string escaping are shared with the
|
||||
# gemini/tabnine renderer in ``specify_cli.integrations.base`` via
|
||||
# ``specify_cli._toml_string`` so the two never drift apart.
|
||||
_has_illegal_toml_control = staticmethod(_has_illegal_toml_control)
|
||||
_render_basic_toml_string = staticmethod(_escape_toml_basic)
|
||||
|
||||
def render_yaml_command(
|
||||
self,
|
||||
@@ -700,6 +701,12 @@ class CommandRegistrar:
|
||||
)
|
||||
output = self.render_toml_command(frontmatter, body, source_id)
|
||||
elif agent_config["format"] == "yaml":
|
||||
body = self.resolve_skill_placeholders(
|
||||
agent_name, frontmatter, body, project_root
|
||||
)
|
||||
body = self._convert_argument_placeholder(
|
||||
body, "$ARGUMENTS", agent_config["args"]
|
||||
)
|
||||
output = self.render_yaml_command(
|
||||
frontmatter, body, source_id, cmd_name
|
||||
)
|
||||
|
||||
@@ -196,7 +196,15 @@ def find_entries_for_url(
|
||||
url: str, entries: list[AuthConfigEntry]
|
||||
) -> list[AuthConfigEntry]:
|
||||
"""Return entries whose ``hosts`` match the hostname of *url*."""
|
||||
hostname = (urlparse(url).hostname or "").lower()
|
||||
# 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 []
|
||||
if not hostname:
|
||||
return []
|
||||
return [
|
||||
|
||||
@@ -73,6 +73,13 @@ class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
|
||||
self._redirect_validator = redirect_validator
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
try:
|
||||
new_parsed = urlparse(newurl)
|
||||
except ValueError as exc:
|
||||
# Malformed redirect target (e.g. unterminated IPv6 bracket).
|
||||
# Surface as URLError so callers' download error handling applies.
|
||||
raise urllib.error.URLError(f"malformed redirect URL: {exc}") from exc
|
||||
|
||||
if self._redirect_validator is not None:
|
||||
self._redirect_validator(req.full_url, newurl)
|
||||
|
||||
@@ -83,7 +90,6 @@ class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
|
||||
new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
if new_req is not None:
|
||||
old_scheme = urlparse(req.full_url).scheme
|
||||
new_parsed = urlparse(newurl)
|
||||
hostname = (new_parsed.hostname or "").lower()
|
||||
is_https_downgrade = old_scheme == "https" and new_parsed.scheme != "https"
|
||||
if _hostname_in_hosts(hostname, self._hosts) and not is_https_downgrade:
|
||||
|
||||
@@ -68,8 +68,18 @@ 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.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
# 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")
|
||||
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}://). "
|
||||
@@ -79,7 +89,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 parsed.hostname:
|
||||
if not hostname:
|
||||
raise BundlerError(
|
||||
f"Catalog '{source_id}' URL must be a valid URL with a host: {url}"
|
||||
)
|
||||
|
||||
@@ -33,12 +33,13 @@ DEFAULT_PRIORITY = 10
|
||||
def _assert_pinned_version(
|
||||
kind: str, component_id: str, pinned: str | None, advertised: object
|
||||
) -> None:
|
||||
"""Refuse to install when the catalog version differs from the manifest pin.
|
||||
"""Refuse to install when the resolved version differs from the manifest pin.
|
||||
|
||||
Bundle manifests pin component versions for reproducibility; installing
|
||||
whatever the active catalog currently serves would silently violate the
|
||||
pin. When the catalog advertises no version we cannot enforce the pin, so
|
||||
installation proceeds (the catalog, not the bundler, owns that gap).
|
||||
whatever the resolved source (catalog *or* bundled asset) provides would
|
||||
silently violate the pin. When the source advertises no version we cannot
|
||||
enforce the pin, so installation proceeds (the source, not the bundler,
|
||||
owns that gap).
|
||||
"""
|
||||
if not pinned or advertised is None:
|
||||
return
|
||||
@@ -54,11 +55,35 @@ def _assert_pinned_version(
|
||||
if not matches:
|
||||
raise BundlerError(
|
||||
f"{kind} '{component_id}' is pinned to version {pinned} in the bundle "
|
||||
f"manifest, but the active catalog serves {actual}. Update the bundle's "
|
||||
"pinned version or the catalog before installing."
|
||||
f"manifest, but the resolved version is {actual}. Update the bundle's "
|
||||
"pinned version or the source before installing."
|
||||
)
|
||||
|
||||
|
||||
def _bundled_manifest_version(manifest_path: Path, root_key: str) -> str | None:
|
||||
"""Best-effort read of a bundled asset's declared version from its manifest.
|
||||
|
||||
Returns ``None`` when the manifest is missing/unreadable/invalid, which
|
||||
``_assert_pinned_version`` treats as "cannot enforce" (proceed) — matching
|
||||
the catalog "advertises no version" escape hatch.
|
||||
"""
|
||||
try:
|
||||
import yaml
|
||||
|
||||
data = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
section = data.get(root_key)
|
||||
if isinstance(section, dict):
|
||||
version = section.get("version")
|
||||
# Only a non-empty string is a usable version; anything else
|
||||
# (missing / non-string / whitespace) means "cannot enforce".
|
||||
if isinstance(version, str) and version.strip():
|
||||
return version
|
||||
except Exception: # noqa: BLE001 - unreadable/invalid manifest: skip pin
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
class _KindManager(Protocol):
|
||||
def is_installed(self, component: ComponentRef) -> bool: ...
|
||||
|
||||
@@ -134,6 +159,15 @@ class _PresetKindManager:
|
||||
|
||||
bundled = _locate_bundled_preset(component.id)
|
||||
if bundled is not None:
|
||||
# Enforce the manifest pin against the bundled asset's own version,
|
||||
# mirroring the catalog path below (the bundled path previously
|
||||
# skipped the pin entirely).
|
||||
_assert_pinned_version(
|
||||
"Preset",
|
||||
component.id,
|
||||
component.version,
|
||||
_bundled_manifest_version(bundled / "preset.yml", "preset"),
|
||||
)
|
||||
self._manager.install_from_directory(bundled, speckit_version, priority)
|
||||
return
|
||||
|
||||
@@ -198,6 +232,15 @@ class _ExtensionKindManager:
|
||||
|
||||
bundled = _locate_bundled_extension(component.id)
|
||||
if bundled is not None:
|
||||
# Enforce the manifest pin against the bundled asset's own version,
|
||||
# mirroring the catalog path below (the bundled path previously
|
||||
# skipped the pin entirely).
|
||||
_assert_pinned_version(
|
||||
"Extension",
|
||||
component.id,
|
||||
component.version,
|
||||
_bundled_manifest_version(bundled / "extension.yml", "extension"),
|
||||
)
|
||||
self._manager.install_from_directory(
|
||||
bundled, speckit_version, priority=priority
|
||||
)
|
||||
|
||||
@@ -71,8 +71,12 @@ class CatalogStackBase:
|
||||
"""Validate that a catalog URL uses HTTPS, except localhost HTTP."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
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")
|
||||
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
||||
raise cls._error(
|
||||
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
|
||||
@@ -81,7 +85,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 parsed.hostname:
|
||||
if not 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:
|
||||
|
||||
@@ -426,7 +426,11 @@ def extension_add(
|
||||
if from_url and not dev:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(from_url)
|
||||
try:
|
||||
parsed = urlparse(from_url)
|
||||
except ValueError:
|
||||
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
|
||||
raise typer.Exit(1)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
|
||||
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
||||
|
||||
@@ -17,6 +17,7 @@ import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from abc import ABC
|
||||
from dataclasses import dataclass
|
||||
@@ -25,6 +26,9 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
import yaml
|
||||
|
||||
from .._toml_string import escape_toml_basic as _escape_toml_basic
|
||||
from .._toml_string import has_illegal_toml_control as _has_illegal_toml_control
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .manifest import IntegrationManifest
|
||||
|
||||
@@ -51,6 +55,18 @@ _CORE_COMMAND_TEMPLATE_RANK = {
|
||||
}
|
||||
|
||||
|
||||
def yaml_quote(value: str) -> str:
|
||||
"""Emit *value* as a double-quoted YAML scalar on a single line.
|
||||
|
||||
A hand-rolled quote cannot carry raw newlines (YAML folds them to
|
||||
spaces) or control characters (the reader rejects them), so let the
|
||||
YAML emitter produce the escapes.
|
||||
"""
|
||||
return yaml.safe_dump(
|
||||
str(value), default_style='"', allow_unicode=True, width=sys.maxsize
|
||||
).strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IntegrationOption
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -589,10 +605,42 @@ class IntegrationBase(ABC):
|
||||
if candidate.exists():
|
||||
return relative
|
||||
for name in ("python3", "python"):
|
||||
if shutil.which(name):
|
||||
return name
|
||||
found = shutil.which(name)
|
||||
if not found:
|
||||
continue
|
||||
# On Windows, python3/python on PATH may be the Microsoft
|
||||
# Store App Execution Alias stub: it exists but only prints
|
||||
# an installer hint and exits non-zero, so existence is not
|
||||
# enough (see #3304 for the same defect in the sh scripts).
|
||||
if sys.platform == "win32" and not IntegrationBase._interpreter_runs(
|
||||
found
|
||||
):
|
||||
continue
|
||||
return name
|
||||
return sys.executable or "python3"
|
||||
|
||||
@staticmethod
|
||||
def _interpreter_runs(path: str) -> bool:
|
||||
"""Return True when *path* executes as a Python interpreter.
|
||||
|
||||
Runs isolated (``-I``) without ``site`` (``-S``) and discards
|
||||
I/O so the probe is a fast liveness check that cannot trigger
|
||||
``sitecustomize``/user startup hooks.
|
||||
"""
|
||||
try:
|
||||
return (
|
||||
subprocess.run(
|
||||
[path, "-I", "-S", "-c", ""],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=15,
|
||||
).returncode
|
||||
== 0
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def process_template(
|
||||
content: str,
|
||||
@@ -953,6 +1001,12 @@ class TomlIntegration(IntegrationBase):
|
||||
body = "".join(lines[frontmatter_end + 1 :])
|
||||
return frontmatter, body
|
||||
|
||||
# Control-char detection and basic-string escaping are shared with the
|
||||
# extension/preset renderer in ``specify_cli.agents`` via
|
||||
# ``specify_cli._toml_string`` so the two never drift apart.
|
||||
_has_illegal_toml_control = staticmethod(_has_illegal_toml_control)
|
||||
_escape_toml_basic = staticmethod(_escape_toml_basic)
|
||||
|
||||
@staticmethod
|
||||
def _render_toml_string(value: str) -> str:
|
||||
"""Render *value* as a TOML string literal.
|
||||
@@ -962,6 +1016,12 @@ class TomlIntegration(IntegrationBase):
|
||||
literal string or escaped basic string when delimiters appear in
|
||||
the content.
|
||||
"""
|
||||
# Control characters other than tab/newline (and a bare CR) cannot
|
||||
# appear literally in any TOML string; route them to a fully-escaped
|
||||
# basic string so the generated file stays parseable.
|
||||
if TomlIntegration._has_illegal_toml_control(value):
|
||||
return TomlIntegration._escape_toml_basic(value)
|
||||
|
||||
if "\n" not in value and "\r" not in value:
|
||||
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return f'"{escaped}"'
|
||||
@@ -974,17 +1034,7 @@ class TomlIntegration(IntegrationBase):
|
||||
if "'''" not in value and not value.endswith("'"):
|
||||
return "'''\n" + value + "'''"
|
||||
|
||||
return (
|
||||
'"'
|
||||
+ (
|
||||
value.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t")
|
||||
)
|
||||
+ '"'
|
||||
)
|
||||
return TomlIntegration._escape_toml_basic(value)
|
||||
|
||||
@staticmethod
|
||||
def _render_toml(description: str, body: str) -> str:
|
||||
@@ -1072,7 +1122,6 @@ class TomlIntegration(IntegrationBase):
|
||||
# YamlIntegration — YAML-format agents (Goose)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class YamlIntegration(IntegrationBase):
|
||||
"""Concrete base for integrations that use YAML recipe format.
|
||||
|
||||
@@ -1475,21 +1524,17 @@ class SkillsIntegration(IntegrationBase):
|
||||
if not description:
|
||||
description = f"Spec Kit: {command_name} workflow"
|
||||
|
||||
# Build SKILL.md with manually formatted frontmatter to match
|
||||
# the release packaging script output exactly (double-quoted
|
||||
# values, no yaml.safe_dump quoting differences).
|
||||
def _quote(v: str) -> str:
|
||||
escaped = v.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return f'"{escaped}"'
|
||||
|
||||
# Build SKILL.md with manually formatted frontmatter (stable
|
||||
# double-quoted values). yaml_quote escapes newlines and control
|
||||
# characters that a plain quoted f-string cannot carry.
|
||||
skill_content = (
|
||||
f"---\n"
|
||||
f"name: {_quote(skill_name)}\n"
|
||||
f"description: {_quote(description)}\n"
|
||||
f"compatibility: {_quote('Requires spec-kit project structure with .specify/ directory')}\n"
|
||||
f"name: {yaml_quote(skill_name)}\n"
|
||||
f"description: {yaml_quote(description)}\n"
|
||||
f"compatibility: {yaml_quote('Requires spec-kit project structure with .specify/ directory')}\n"
|
||||
f"metadata:\n"
|
||||
f" author: {_quote('github-spec-kit')}\n"
|
||||
f" source: {_quote('templates/commands/' + src_file.name)}\n"
|
||||
f" author: {yaml_quote('github-spec-kit')}\n"
|
||||
f" source: {yaml_quote('templates/commands/' + src_file.name)}\n"
|
||||
f"---\n"
|
||||
f"{processed_body}"
|
||||
)
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from ..base import IntegrationOption, SkillsIntegration
|
||||
from ..base import IntegrationOption, SkillsIntegration, yaml_quote
|
||||
from ..manifest import IntegrationManifest
|
||||
|
||||
|
||||
@@ -153,20 +153,18 @@ class HermesIntegration(SkillsIntegration):
|
||||
if not description:
|
||||
description = f"Spec Kit: {command_name} workflow"
|
||||
|
||||
# Build SKILL.md with manually formatted frontmatter
|
||||
def _quote(v: str) -> str:
|
||||
escaped = v.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return f'"{escaped}"'
|
||||
|
||||
# Build SKILL.md with manually formatted frontmatter. yaml_quote
|
||||
# escapes newlines and control characters that a plain quoted
|
||||
# f-string cannot carry.
|
||||
skill_content = (
|
||||
f"---\n"
|
||||
f"name: {_quote(skill_name)}\n"
|
||||
f"description: {_quote(description)}\n"
|
||||
f"name: {yaml_quote(skill_name)}\n"
|
||||
f"description: {yaml_quote(description)}\n"
|
||||
f"compatibility: "
|
||||
f"{_quote('Requires spec-kit project structure with .specify/ directory')}\n"
|
||||
f"{yaml_quote('Requires spec-kit project structure with .specify/ directory')}\n"
|
||||
f"metadata:\n"
|
||||
f" author: {_quote('github-spec-kit')}\n"
|
||||
f" source: {_quote('templates/commands/' + src_file.name)}\n"
|
||||
f" author: {yaml_quote('github-spec-kit')}\n"
|
||||
f" source: {yaml_quote('templates/commands/' + src_file.name)}\n"
|
||||
f"---\n"
|
||||
f"{processed_body}"
|
||||
)
|
||||
|
||||
@@ -309,7 +309,14 @@ class IntegrationManifest:
|
||||
if abs_path.is_symlink() or not abs_path.is_file():
|
||||
modified.append(rel)
|
||||
continue
|
||||
if _sha256(abs_path) != expected_hash:
|
||||
try:
|
||||
changed = _sha256(abs_path) != expected_hash
|
||||
except OSError:
|
||||
# Unreadable regular file (e.g. permission denied): treat as
|
||||
# modified, consistent with the symlink / non-regular-file
|
||||
# handling above, rather than letting the OSError escape.
|
||||
changed = True
|
||||
if changed:
|
||||
modified.append(rel)
|
||||
return modified
|
||||
|
||||
@@ -358,9 +365,17 @@ class IntegrationManifest:
|
||||
skipped.append(path)
|
||||
continue
|
||||
else:
|
||||
if not force and _sha256(path) != expected_hash:
|
||||
skipped.append(path)
|
||||
continue
|
||||
if not force:
|
||||
try:
|
||||
matches = _sha256(path) == expected_hash
|
||||
except OSError:
|
||||
# Unreadable: can't verify it's ours, so preserve it
|
||||
# (mirrors the path.unlink() OSError guard below).
|
||||
skipped.append(path)
|
||||
continue
|
||||
if not matches:
|
||||
skipped.append(path)
|
||||
continue
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
|
||||
@@ -104,7 +104,13 @@ def preset_add(
|
||||
from ipaddress import ip_address
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
|
||||
_parsed = _urlparse(from_url)
|
||||
try:
|
||||
_parsed = _urlparse(from_url)
|
||||
except ValueError:
|
||||
from rich.markup import escape as _escape_markup
|
||||
|
||||
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
def _is_allowed_download_url(parsed_url):
|
||||
host = parsed_url.hostname
|
||||
@@ -135,7 +141,9 @@ def preset_add(
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(f"Installing preset from [cyan]{from_url}[/cyan]...")
|
||||
from rich.markup import escape as _esc
|
||||
|
||||
console.print(f"Installing preset from [cyan]{_esc(from_url)}[/cyan]...")
|
||||
import urllib.error
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
@@ -328,7 +328,10 @@ def refresh_shared_templates(
|
||||
_ensure_safe_shared_destination(project_path, dst)
|
||||
rel = dst.relative_to(project_path).as_posix()
|
||||
if dst.exists() and not force:
|
||||
if rel not in tracked_files or rel in modified:
|
||||
if rel not in tracked_files or rel in modified or manifest.is_recovered(rel):
|
||||
# Never overwrite a recovered (pre-existing user) file without
|
||||
# --force, matching install_shared_infra's is_recovered gate
|
||||
# (#2918). Without this, refresh clobbers user content.
|
||||
skipped_files.append(rel)
|
||||
continue
|
||||
|
||||
@@ -344,7 +347,7 @@ def refresh_shared_templates(
|
||||
|
||||
if skipped_files:
|
||||
console.print(
|
||||
f"[yellow]⚠[/yellow] {len(skipped_files)} modified or untracked shared template file(s) were not updated:"
|
||||
f"[yellow]⚠[/yellow] {len(skipped_files)} modified, untracked, or preserved (recovered) shared template file(s) were not updated:"
|
||||
)
|
||||
for rel in skipped_files:
|
||||
console.print(f" {rel}")
|
||||
@@ -400,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 = "bash" if script_type == "sh" else "powershell"
|
||||
variant_dir = {"sh": "bash", "py": "python"}.get(script_type, "powershell")
|
||||
|
||||
def _decide_overwrite(rel: str, dst: Path) -> tuple[bool, str | None]:
|
||||
"""Return (write, bucket) where bucket is 'skip', 'preserved', or None."""
|
||||
@@ -462,6 +465,10 @@ 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,7 +601,15 @@ def workflow_add(
|
||||
except (ValueError, yaml.YAMLError) as exc:
|
||||
console.print(f"[red]Error:[/red] Invalid workflow YAML: {exc}")
|
||||
raise typer.Exit(1)
|
||||
if not definition.id or not definition.id.strip():
|
||||
# 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())
|
||||
):
|
||||
console.print("[red]Error:[/red] Workflow definition has an empty or missing 'id'")
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -631,7 +639,11 @@ def workflow_add(
|
||||
from urllib.parse import urlparse
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
|
||||
parsed_src = urlparse(source)
|
||||
try:
|
||||
parsed_src = urlparse(source)
|
||||
except ValueError:
|
||||
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(source)}")
|
||||
raise typer.Exit(1)
|
||||
src_host = parsed_src.hostname or ""
|
||||
src_loopback = src_host == "localhost"
|
||||
if not src_loopback:
|
||||
|
||||
@@ -129,26 +129,49 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
|
||||
errors: list[str] = []
|
||||
|
||||
# -- Schema version ---------------------------------------------------
|
||||
if definition.schema_version not in ("1.0", "1"):
|
||||
# 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":
|
||||
errors.append(
|
||||
f"Unsupported schema_version {definition.schema_version!r}. "
|
||||
f"Expected '1.0'."
|
||||
)
|
||||
|
||||
# -- Top-level fields -------------------------------------------------
|
||||
if not definition.id:
|
||||
# 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 == "":
|
||||
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 not definition.name:
|
||||
if definition.name is None or 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 not definition.version:
|
||||
if definition.version is None or 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 "
|
||||
@@ -256,9 +279,15 @@ def _validate_steps(
|
||||
continue
|
||||
|
||||
step_id = step_config.get("id")
|
||||
if not step_id:
|
||||
if step_id is None or 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(
|
||||
|
||||
@@ -242,6 +242,26 @@ def _interpolate_expressions(template: str, namespace: dict[str, Any]) -> str:
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _split_top_level(text: str, sep: str) -> list[str]:
|
||||
"""Split *text* on each occurrence of *sep* that lies outside any quoted
|
||||
string or nested brackets.
|
||||
|
||||
Used to break a filter chain (``a | map('x') | join(',')``) into its
|
||||
individual filter segments without splitting on a ``|`` that appears inside
|
||||
a quoted argument. Each returned segment is a slice at a top-level
|
||||
boundary, so the quote/bracket scan restarts cleanly on the remainder.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
start = 0
|
||||
while True:
|
||||
idx = _find_top_level(text[start:], sep)
|
||||
if idx == -1:
|
||||
parts.append(text[start:])
|
||||
return parts
|
||||
parts.append(text[start:start + idx])
|
||||
start += idx + len(sep)
|
||||
|
||||
|
||||
def _split_top_level_commas(text: str) -> list[str]:
|
||||
"""Split *text* on commas that are not inside quotes or nested brackets.
|
||||
|
||||
@@ -305,6 +325,68 @@ def _find_top_level(text: str, token: str) -> int:
|
||||
return -1
|
||||
|
||||
|
||||
def _apply_filter(value: Any, filter_expr: str, namespace: dict[str, Any]) -> Any:
|
||||
"""Apply a single pipe filter segment to *value*.
|
||||
|
||||
*filter_expr* is one link of a filter chain — the text between two
|
||||
top-level ``|`` separators, already stripped (e.g. ``map('name')``,
|
||||
``default('x')``, ``from_json``). Returns the filtered value so the caller
|
||||
can feed it into the next link.
|
||||
|
||||
Raises ``ValueError`` on any mis-wired or unknown filter rather than
|
||||
silently returning *value* unchanged: a passthrough would turn a mistyped
|
||||
or unsupported filter into a wrong result with no signal.
|
||||
"""
|
||||
# `from_json` is strict: it takes no arguments and tolerates no trailing
|
||||
# tokens. Match on the leading filter name and require the whole filter to
|
||||
# be exactly `from_json`, so every mis-wired form (`from_json()`,
|
||||
# `from_json('x')`, `from_json)`, `from_json extra`) fails loudly instead of
|
||||
# silently falling through to the unknown-filter path.
|
||||
leading = re.match(r"\w+", filter_expr)
|
||||
if leading and leading.group(0) == "from_json":
|
||||
if filter_expr != "from_json":
|
||||
raise ValueError(
|
||||
"from_json: expected '| from_json' with no arguments or "
|
||||
f"trailing tokens, got '| {filter_expr}'"
|
||||
)
|
||||
return _filter_from_json(value)
|
||||
|
||||
# Parse filter name and argument
|
||||
filter_match = re.match(r"(\w+)\((.+)\)", filter_expr)
|
||||
if filter_match:
|
||||
fname = filter_match.group(1)
|
||||
farg = _evaluate_simple_expression(filter_match.group(2).strip(), namespace)
|
||||
if fname == "default":
|
||||
return _filter_default(value, farg)
|
||||
if fname == "join":
|
||||
return _filter_join(value, farg)
|
||||
if fname == "map":
|
||||
return _filter_map(value, farg)
|
||||
if fname == "contains":
|
||||
return _filter_contains(value, farg)
|
||||
# Filter without args
|
||||
if filter_expr == "default":
|
||||
return _filter_default(value)
|
||||
# No recognized filter matched. Fail loudly rather than silently returning
|
||||
# the unfiltered value. Distinguish a *registered* filter used in an
|
||||
# unsupported form (e.g. `| join` or `| map` with no argument) from a
|
||||
# genuinely unknown filter name, so the message names the real problem
|
||||
# instead of calling a known filter "unknown".
|
||||
name = leading.group(0) if leading else filter_expr
|
||||
expected = (
|
||||
"expected one of default or default('x'), join('sep'), "
|
||||
"map('attr'), contains('s'), or from_json"
|
||||
)
|
||||
if name in _REGISTERED_FILTERS:
|
||||
raise ValueError(
|
||||
f"filter '{name}' used in an unsupported form (got "
|
||||
f"'| {filter_expr}'): {expected}"
|
||||
)
|
||||
raise ValueError(
|
||||
f"unknown filter '{name}': {expected} (got '| {filter_expr}')"
|
||||
)
|
||||
|
||||
|
||||
def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
|
||||
"""Evaluate a simple expression against the namespace.
|
||||
|
||||
@@ -329,65 +411,17 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
|
||||
# Handle pipe filters. Detect the pipe at the top level only, so a literal
|
||||
# '|' inside a quoted operand (e.g. `inputs.x == 'a|b'`) or nested brackets is
|
||||
# not mistaken for a filter separator — mirroring the operator parsing below.
|
||||
# Filters chain left-to-right: `list | map('name') | join(', ')` feeds each
|
||||
# filter's result into the next, so `map` (which yields a list) can be
|
||||
# rendered by `join`. Splitting only at the first pipe would hand the whole
|
||||
# tail to one filter and mangle any later `|`.
|
||||
pipe_idx = _find_top_level(expr, "|")
|
||||
if pipe_idx != -1:
|
||||
value = _evaluate_simple_expression(expr[:pipe_idx].strip(), namespace)
|
||||
filter_expr = expr[pipe_idx + 1:].strip()
|
||||
|
||||
# `from_json` is strict: it takes no arguments and tolerates no
|
||||
# trailing tokens. Match on the leading filter name and require the
|
||||
# whole filter to be exactly `from_json`, so every mis-wired form
|
||||
# (`from_json()`, `from_json('x')`, `from_json)`, `from_json extra`)
|
||||
# fails loudly instead of silently falling through to the
|
||||
# unknown-filter path and returning the unparsed value. (filter_expr
|
||||
# is already stripped above.)
|
||||
leading = re.match(r"\w+", filter_expr)
|
||||
if leading and leading.group(0) == "from_json":
|
||||
if filter_expr != "from_json":
|
||||
raise ValueError(
|
||||
"from_json: expected '| from_json' with no arguments or "
|
||||
f"trailing tokens, got '| {filter_expr}'"
|
||||
)
|
||||
return _filter_from_json(value)
|
||||
|
||||
# Parse filter name and argument
|
||||
filter_match = re.match(r"(\w+)\((.+)\)", filter_expr)
|
||||
if filter_match:
|
||||
fname = filter_match.group(1)
|
||||
farg = _evaluate_simple_expression(filter_match.group(2).strip(), namespace)
|
||||
if fname == "default":
|
||||
return _filter_default(value, farg)
|
||||
if fname == "join":
|
||||
return _filter_join(value, farg)
|
||||
if fname == "map":
|
||||
return _filter_map(value, farg)
|
||||
if fname == "contains":
|
||||
return _filter_contains(value, farg)
|
||||
# Filter without args
|
||||
filter_name = filter_expr.strip()
|
||||
if filter_name == "default":
|
||||
return _filter_default(value)
|
||||
# No recognized filter matched. Fail loudly rather than silently
|
||||
# returning the unfiltered value: a passthrough turns a mis-typed or
|
||||
# unsupported filter into a wrong result with no signal. Mirrors the
|
||||
# strict `from_json` handling above. Distinguish a *registered* filter
|
||||
# used in an unsupported form (e.g. `| join` or `| map` with no
|
||||
# argument) from a genuinely unknown filter name, so the message names
|
||||
# the real problem instead of calling a known filter "unknown".
|
||||
leading_name = re.match(r"\w+", filter_expr)
|
||||
name = leading_name.group(0) if leading_name else filter_expr
|
||||
expected = (
|
||||
"expected one of default or default('x'), join('sep'), "
|
||||
"map('attr'), contains('s'), or from_json"
|
||||
)
|
||||
if name in _REGISTERED_FILTERS:
|
||||
raise ValueError(
|
||||
f"filter '{name}' used in an unsupported form (got "
|
||||
f"'| {filter_expr}'): {expected}"
|
||||
)
|
||||
raise ValueError(
|
||||
f"unknown filter '{name}': {expected} (got '| {filter_expr}')"
|
||||
)
|
||||
segments = _split_top_level(expr, "|")
|
||||
value = _evaluate_simple_expression(segments[0].strip(), namespace)
|
||||
for segment in segments[1:]:
|
||||
value = _apply_filter(value, segment.strip(), namespace)
|
||||
return value
|
||||
|
||||
# Boolean operators — parse 'or' first (lower precedence) so that
|
||||
# 'a or b and c' is evaluated as 'a or (b and c)'. Splits are quote/bracket
|
||||
|
||||
@@ -25,6 +25,14 @@ class ShellStep(StepBase):
|
||||
run_cmd = str(run_cmd)
|
||||
|
||||
cwd = context.project_root or "."
|
||||
# Defensive: the engine does not auto-validate step config, so an
|
||||
# invalid ``timeout`` (string, None, ...) would otherwise raise a
|
||||
# TypeError from subprocess.run() and crash the whole run. Mirror
|
||||
# the engine's handling of unvalidated ``continue_on_error`` by
|
||||
# only honoring well-formed values and falling back to the default.
|
||||
timeout = config.get("timeout", 300)
|
||||
if isinstance(timeout, bool) or not isinstance(timeout, int) or timeout <= 0:
|
||||
timeout = 300
|
||||
|
||||
# NOTE: shell=True is required to support pipes, redirects, and
|
||||
# multi-command expressions in workflow YAML. Workflow authors
|
||||
@@ -37,7 +45,7 @@ class ShellStep(StepBase):
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd,
|
||||
timeout=300,
|
||||
timeout=timeout,
|
||||
)
|
||||
output = {
|
||||
"exit_code": proc.returncode,
|
||||
@@ -74,7 +82,7 @@ class ShellStep(StepBase):
|
||||
except subprocess.TimeoutExpired:
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error="Shell command timed out after 300 seconds.",
|
||||
error=f"Shell command timed out after {timeout} seconds.",
|
||||
output={"exit_code": -1, "stdout": "", "stderr": "timeout"},
|
||||
)
|
||||
except OSError as exc:
|
||||
@@ -106,4 +114,16 @@ class ShellStep(StepBase):
|
||||
f"Shell step {config.get('id', '?')!r}: 'output_format' must "
|
||||
f"be 'json' when present, got {output_format!r}."
|
||||
)
|
||||
if "timeout" in config:
|
||||
timeout = config["timeout"]
|
||||
# bool is an int subclass, so reject it explicitly.
|
||||
if (
|
||||
isinstance(timeout, bool)
|
||||
or not isinstance(timeout, int)
|
||||
or timeout <= 0
|
||||
):
|
||||
errors.append(
|
||||
f"Shell step {config.get('id', '?')!r}: 'timeout' must be a "
|
||||
f"positive integer (seconds) when present, got {timeout!r}."
|
||||
)
|
||||
return errors
|
||||
|
||||
@@ -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,6 +3,7 @@ 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,6 +3,7 @@ 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,12 +1,13 @@
|
||||
---
|
||||
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...
|
||||
|
||||
@@ -3,6 +3,7 @@ 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,6 +3,7 @@ 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
|
||||
@@ -42,7 +43,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.
|
||||
@@ -144,7 +145,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,7 +68,6 @@ 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
|
||||
@@ -107,7 +106,7 @@ Check if `.specify/extensions.yml` exists in the project root.
|
||||
|
||||
## Completion Report
|
||||
|
||||
Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts.
|
||||
Command ends after Phase 1 design. 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,6 +4,7 @@ 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. See `.specify/templates/plan-template.md` for the execution workflow.
|
||||
**Note**: This template is filled in by the `__SPECKIT_COMMAND_PLAN__` command; its definition describes the execution workflow.
|
||||
|
||||
## Summary
|
||||
|
||||
|
||||
@@ -11,4 +11,3 @@
|
||||
".specify/scripts/powershell/": true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -638,6 +638,21 @@ 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:
|
||||
@@ -942,6 +957,23 @@ 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,6 +25,14 @@ 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:
|
||||
@@ -279,12 +287,14 @@ def shlex_quote(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _run_powershell_agent_context_script(project_root: Path) -> subprocess.CompletedProcess:
|
||||
def _run_powershell_agent_context_script(
|
||||
project_root: Path, powershell: str | None = None
|
||||
) -> subprocess.CompletedProcess:
|
||||
script = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1"
|
||||
env = _bundled_script_env(project_root)
|
||||
return subprocess.run(
|
||||
[
|
||||
POWERSHELL,
|
||||
powershell or POWERSHELL,
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
@@ -412,6 +422,29 @@ 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"
|
||||
@@ -484,6 +517,33 @@ 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"
|
||||
@@ -688,6 +748,62 @@ 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."""
|
||||
|
||||
481
tests/extensions/test_update_agent_context_python_parity.py
Normal file
481
tests/extensions/test_update_agent_context_python_parity.py
Normal file
@@ -0,0 +1,481 @@
|
||||
"""Parity tests: update_agent_context.py vs update-agent-context.sh/.ps1.
|
||||
|
||||
Each test prepares two identical project trees, runs the bash script in one
|
||||
and the Python port in the other, then compares exit codes, output (with
|
||||
project roots normalized) and the resulting context-file bytes. PowerShell
|
||||
tests compare the resulting file content only and are skipped when ``pwsh``
|
||||
is unavailable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.extensions.test_extension_agent_context import (
|
||||
BASH,
|
||||
EXT_DIR,
|
||||
POWERSHELL,
|
||||
_bundled_script_env,
|
||||
)
|
||||
|
||||
PY_SCRIPT = EXT_DIR / "scripts" / "python" / "update_agent_context.py"
|
||||
BASH_SCRIPT = EXT_DIR / "scripts" / "bash" / "update-agent-context.sh"
|
||||
PS_SCRIPT = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1"
|
||||
|
||||
requires_posix_bash = pytest.mark.skipif(
|
||||
not BASH or os.name == "nt",
|
||||
reason="POSIX bash required for side-by-side parity runs",
|
||||
)
|
||||
|
||||
|
||||
def run_bash(project_root: Path, *args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[BASH, str(BASH_SCRIPT), *args],
|
||||
cwd=project_root,
|
||||
env=_bundled_script_env(project_root, for_bash=True),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def run_python(project_root: Path, *args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(PY_SCRIPT), *args],
|
||||
cwd=project_root,
|
||||
env=_bundled_script_env(project_root),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def run_powershell(project_root: Path, *args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[
|
||||
POWERSHELL,
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(PS_SCRIPT),
|
||||
*args,
|
||||
],
|
||||
cwd=project_root,
|
||||
env=_bundled_script_env(project_root),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def normalize(text: str, project_root: Path) -> str:
|
||||
return text.replace(str(project_root.resolve()), "__ROOT__").replace(
|
||||
str(project_root), "__ROOT__"
|
||||
)
|
||||
|
||||
|
||||
def write_config(project_root: Path, **overrides: object) -> None:
|
||||
"""Write the extension config as JSON (valid YAML, PS-parseable too)."""
|
||||
cfg: dict = {
|
||||
"context_file": overrides.get("context_file", ""),
|
||||
"context_files": overrides.get("context_files", []),
|
||||
"context_markers": overrides.get(
|
||||
"context_markers",
|
||||
{"start": "<!-- SPECKIT START -->", "end": "<!-- SPECKIT END -->"},
|
||||
),
|
||||
}
|
||||
cfg_dir = project_root / ".specify" / "extensions" / "agent-context"
|
||||
cfg_dir.mkdir(parents=True, exist_ok=True)
|
||||
(cfg_dir / "agent-context-config.yml").write_text(
|
||||
json.dumps(cfg), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def make_project(root: Path, **config: object) -> Path:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
write_config(root, **config)
|
||||
return root
|
||||
|
||||
|
||||
def add_plan(project_root: Path, feature_dir: str = "specs/001-demo") -> None:
|
||||
plan = project_root / feature_dir / "plan.md"
|
||||
plan.parent.mkdir(parents=True, exist_ok=True)
|
||||
plan.write_text("# plan\n", encoding="utf-8")
|
||||
(project_root / ".specify").mkdir(parents=True, exist_ok=True)
|
||||
(project_root / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": feature_dir}), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def twin_projects(tmp_path: Path, **config: object) -> tuple[Path, Path]:
|
||||
return (
|
||||
make_project(tmp_path / "proj-a", **config),
|
||||
make_project(tmp_path / "proj-b", **config),
|
||||
)
|
||||
|
||||
|
||||
def assert_parity(
|
||||
bash: subprocess.CompletedProcess,
|
||||
py: subprocess.CompletedProcess,
|
||||
repo_a: Path,
|
||||
repo_b: Path,
|
||||
) -> None:
|
||||
assert py.returncode == bash.returncode, py.stderr + bash.stderr
|
||||
assert normalize(py.stdout, repo_b) == normalize(bash.stdout, repo_a)
|
||||
assert normalize(py.stderr, repo_b) == normalize(bash.stderr, repo_a)
|
||||
|
||||
|
||||
# ── Fresh file and upsert behavior ───────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_creates_fresh_context_file_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
add_plan(repo_a)
|
||||
add_plan(repo_b)
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content_a = (repo_a / "AGENTS.md").read_bytes()
|
||||
content_b = (repo_b / "AGENTS.md").read_bytes()
|
||||
assert content_a == content_b
|
||||
assert b"at specs/001-demo/plan.md" in content_b
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_replaces_existing_section_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
existing = (
|
||||
"# My project\n\n"
|
||||
"<!-- SPECKIT START -->\nstale section\n<!-- SPECKIT END -->\n"
|
||||
"\nTrailing prose stays.\n"
|
||||
)
|
||||
for repo in (repo_a, repo_b):
|
||||
add_plan(repo)
|
||||
(repo / "AGENTS.md").write_text(existing, encoding="utf-8")
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_text(encoding="utf-8")
|
||||
assert content == (repo_a / "AGENTS.md").read_text(encoding="utf-8")
|
||||
assert "stale section" not in content
|
||||
assert content.startswith("# My project\n")
|
||||
assert "Trailing prose stays." in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
@pytest.mark.parametrize(
|
||||
"existing",
|
||||
[
|
||||
"# Doc\n<!-- SPECKIT START -->\ndangling start\n",
|
||||
"dangling end\n<!-- SPECKIT END -->\nrest\n",
|
||||
"no markers at all",
|
||||
],
|
||||
ids=["start-only", "end-only", "no-markers-no-newline"],
|
||||
)
|
||||
def test_python_handles_partial_markers_matching_bash(
|
||||
tmp_path: Path, existing: str
|
||||
) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
for repo in (repo_a, repo_b):
|
||||
add_plan(repo)
|
||||
(repo / "AGENTS.md").write_text(existing, encoding="utf-8")
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
assert (repo_a / "AGENTS.md").read_bytes() == (repo_b / "AGENTS.md").read_bytes()
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_custom_markers_matching_bash(tmp_path: Path) -> None:
|
||||
markers = {"start": "<!-- CTX BEGIN -->", "end": "<!-- CTX FINISH -->"}
|
||||
repo_a, repo_b = twin_projects(
|
||||
tmp_path, context_file="AGENTS.md", context_markers=markers
|
||||
)
|
||||
existing = "intro\n<!-- CTX BEGIN -->\nold\n<!-- CTX FINISH -->\noutro\n"
|
||||
for repo in (repo_a, repo_b):
|
||||
add_plan(repo)
|
||||
(repo / "AGENTS.md").write_text(existing, encoding="utf-8")
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_text(encoding="utf-8")
|
||||
assert content == (repo_a / "AGENTS.md").read_text(encoding="utf-8")
|
||||
assert "<!-- CTX BEGIN -->" in content
|
||||
assert "old" not in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_multiple_context_files_dedup_matching_bash(tmp_path: Path) -> None:
|
||||
files = ["AGENTS.md", "docs/CONTEXT.md", "AGENTS.md"]
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_files=files)
|
||||
add_plan(repo_a)
|
||||
add_plan(repo_b)
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
assert bash.stdout.count("agent-context: updated") == 2
|
||||
for name in ("AGENTS.md", "docs/CONTEXT.md"):
|
||||
assert (repo_a / name).read_bytes() == (repo_b / name).read_bytes()
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_normalizes_crlf_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
existing = b"# Doc\r\n\r\n<!-- SPECKIT START -->\r\nold\r\n<!-- SPECKIT END -->\r\ntail\r\n"
|
||||
for repo in (repo_a, repo_b):
|
||||
add_plan(repo)
|
||||
(repo / "AGENTS.md").write_bytes(existing)
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_bytes()
|
||||
assert content == (repo_a / "AGENTS.md").read_bytes()
|
||||
assert b"\r" not in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_mdc_frontmatter_repair_matching_bash(tmp_path: Path) -> None:
|
||||
mdc = ".cursor/rules/specify-rules.mdc"
|
||||
cases = {
|
||||
"missing": "# Rules\n",
|
||||
"false-value": "---\ndescription: rules\nalwaysApply: false\n---\n\n# Rules\n",
|
||||
"no-key": "---\ndescription: rules\n---\n\n# Rules\n",
|
||||
}
|
||||
for name, existing in cases.items():
|
||||
repo_a = make_project(tmp_path / f"a-{name}", context_file=mdc)
|
||||
repo_b = make_project(tmp_path / f"b-{name}", context_file=mdc)
|
||||
for repo in (repo_a, repo_b):
|
||||
add_plan(repo)
|
||||
target = repo / mdc
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(existing, encoding="utf-8")
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / mdc).read_text(encoding="utf-8")
|
||||
assert content == (repo_a / mdc).read_text(encoding="utf-8"), name
|
||||
assert "alwaysApply: true" in content, name
|
||||
|
||||
|
||||
# ── Plan-path resolution ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_explicit_plan_argument_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
|
||||
bash = run_bash(repo_a, "specs/009-explicit/plan.md")
|
||||
py = run_python(repo_b, "specs/009-explicit/plan.md")
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_bytes()
|
||||
assert content == (repo_a / "AGENTS.md").read_bytes()
|
||||
assert b"at specs/009-explicit/plan.md" in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_mtime_fallback_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
now = time.time()
|
||||
for repo in (repo_a, repo_b):
|
||||
for feature, age in (("specs/000-old", 10), ("specs/001-new", 0)):
|
||||
plan = repo / feature / "plan.md"
|
||||
plan.parent.mkdir(parents=True, exist_ok=True)
|
||||
plan.write_text("# plan\n", encoding="utf-8")
|
||||
os.utime(plan, (now - age, now - age))
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_bytes()
|
||||
assert content == (repo_a / "AGENTS.md").read_bytes()
|
||||
assert b"at specs/001-new/plan.md" in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_prefers_feature_json_over_mtime_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
now = time.time()
|
||||
for repo in (repo_a, repo_b):
|
||||
add_plan(repo, "specs/001-active")
|
||||
stale = repo / "specs" / "000-stale" / "plan.md"
|
||||
stale.parent.mkdir(parents=True, exist_ok=True)
|
||||
stale.write_text("# plan\n", encoding="utf-8")
|
||||
os.utime(repo / "specs" / "001-active" / "plan.md", (now - 10, now - 10))
|
||||
os.utime(stale, (now, now))
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_bytes()
|
||||
assert content == (repo_a / "AGENTS.md").read_bytes()
|
||||
assert b"at specs/001-active/plan.md" in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_no_plan_omits_at_line_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_bytes()
|
||||
assert content == (repo_a / "AGENTS.md").read_bytes()
|
||||
assert b"\nat " not in content
|
||||
|
||||
|
||||
# ── Config gates and path validation ─────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_missing_config_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a = tmp_path / "proj-a"
|
||||
repo_b = tmp_path / "proj-b"
|
||||
repo_a.mkdir()
|
||||
repo_b.mkdir()
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
assert py.returncode == 0
|
||||
assert "not found; nothing to do." in py.stderr
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_unparseable_config_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a = tmp_path / "proj-a"
|
||||
repo_b = tmp_path / "proj-b"
|
||||
for repo in (repo_a, repo_b):
|
||||
cfg_dir = repo / ".specify" / "extensions" / "agent-context"
|
||||
cfg_dir.mkdir(parents=True)
|
||||
(cfg_dir / "agent-context-config.yml").write_text(
|
||||
"context_file: [unclosed\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
assert py.returncode == 0
|
||||
assert "cannot update context." in py.stderr
|
||||
assert "agent-context: skipping update (see above for details)." in py.stderr
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_empty_config_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path)
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
assert py.returncode == 0
|
||||
assert "context_files/context_file not set" in py.stderr
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_self_seed_from_init_options_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path)
|
||||
for repo in (repo_a, repo_b):
|
||||
add_plan(repo)
|
||||
(repo / ".specify" / "init-options.json").write_text(
|
||||
json.dumps({"integration": "claude"}), encoding="utf-8"
|
||||
)
|
||||
shutil.copy(
|
||||
EXT_DIR / "agent-context-defaults.json",
|
||||
repo
|
||||
/ ".specify"
|
||||
/ "extensions"
|
||||
/ "agent-context"
|
||||
/ "agent-context-defaults.json",
|
||||
)
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
assert (repo_a / "CLAUDE.md").read_bytes() == (repo_b / "CLAUDE.md").read_bytes()
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
@pytest.mark.parametrize(
|
||||
"bad_path",
|
||||
["/etc/AGENTS.md", "docs\\AGENTS.md", "../outside.md", "nested/../../escape.md"],
|
||||
ids=["absolute", "backslash", "dotdot", "nested-dotdot"],
|
||||
)
|
||||
def test_python_rejects_escaping_paths_matching_bash(
|
||||
tmp_path: Path, bad_path: str
|
||||
) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file=bad_path)
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
assert py.returncode == 1
|
||||
assert not (repo_b / "AGENTS.md").exists()
|
||||
|
||||
|
||||
# ── PowerShell parity (content only) ─────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
|
||||
def test_python_fresh_context_file_matches_powershell(tmp_path: Path) -> None:
|
||||
repo_a = make_project(tmp_path / "proj-ps", context_file="AGENTS.md")
|
||||
repo_b = make_project(tmp_path / "proj-py", context_file="AGENTS.md")
|
||||
add_plan(repo_a)
|
||||
add_plan(repo_b)
|
||||
|
||||
ps = run_powershell(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert ps.returncode == py.returncode == 0, ps.stderr + py.stderr
|
||||
assert (repo_a / "AGENTS.md").read_bytes() == (repo_b / "AGENTS.md").read_bytes()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
|
||||
def test_python_upsert_matches_powershell(tmp_path: Path) -> None:
|
||||
repo_a = make_project(tmp_path / "proj-ps", context_file="AGENTS.md")
|
||||
repo_b = make_project(tmp_path / "proj-py", context_file="AGENTS.md")
|
||||
existing = (
|
||||
"# My project\n\n"
|
||||
"<!-- SPECKIT START -->\nstale\n<!-- SPECKIT END -->\n"
|
||||
"\ntail\n"
|
||||
)
|
||||
for repo in (repo_a, repo_b):
|
||||
add_plan(repo)
|
||||
(repo / "AGENTS.md").write_text(existing, encoding="utf-8")
|
||||
|
||||
ps = run_powershell(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert ps.returncode == py.returncode == 0, ps.stderr + py.stderr
|
||||
assert (repo_a / "AGENTS.md").read_bytes() == (repo_b / "AGENTS.md").read_bytes()
|
||||
@@ -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`.
|
||||
|
||||
@@ -5,10 +5,8 @@ import pytest
|
||||
from specify_cli.integrations.base import MarkdownIntegration
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_integration_home(monkeypatch: pytest.MonkeyPatch, tmp_path):
|
||||
"""Keep integration tests from reading or writing the real user home."""
|
||||
home = tmp_path / "home"
|
||||
def _redirect_home(monkeypatch: pytest.MonkeyPatch, home) -> None:
|
||||
"""Point HOME/USERPROFILE/XDG env vars at an isolated *home* directory."""
|
||||
for path in (home, home / ".cache", home / ".config", home / ".local" / "share"):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -19,6 +17,28 @@ def _isolate_integration_home(monkeypatch: pytest.MonkeyPatch, tmp_path):
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(home / ".local" / "share"))
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _isolate_integration_home_session(tmp_path_factory):
|
||||
"""Isolate the user home for setup that runs outside a test function.
|
||||
|
||||
The per-test fixture below re-points HOME for each test, but function-scoped
|
||||
fixtures do not apply to module-/session-scoped fixtures. Some of those (e.g.
|
||||
the ``status_*_template`` fixtures in ``test_integration_subcommand.py``) run
|
||||
``specify init`` during setup, before any per-test isolation takes effect.
|
||||
A standalone ``MonkeyPatch`` gives them an isolated home too.
|
||||
"""
|
||||
monkeypatch = pytest.MonkeyPatch()
|
||||
_redirect_home(monkeypatch, tmp_path_factory.mktemp("session-home"))
|
||||
yield
|
||||
monkeypatch.undo()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_integration_home(monkeypatch: pytest.MonkeyPatch, tmp_path):
|
||||
"""Keep integration tests from reading or writing the real user home."""
|
||||
_redirect_home(monkeypatch, tmp_path / "home")
|
||||
|
||||
|
||||
class StubIntegration(MarkdownIntegration):
|
||||
"""Minimal concrete integration for testing."""
|
||||
|
||||
|
||||
@@ -306,9 +306,12 @@ class TestResolveCommandRefs:
|
||||
class TestResolvePythonInterpreter:
|
||||
def test_returns_python_on_path(self, monkeypatch):
|
||||
# Positive: when python3 is on PATH it is preferred over python.
|
||||
# Pin a POSIX platform so the Windows stub probe (tested separately
|
||||
# below) does not reject the fake PATH entries on Windows CI.
|
||||
def fake_which(name):
|
||||
return f"/usr/bin/{name}" if name in ("python3", "python") else None
|
||||
|
||||
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which", fake_which
|
||||
)
|
||||
@@ -318,6 +321,7 @@ class TestResolvePythonInterpreter:
|
||||
def fake_which(name):
|
||||
return "/usr/bin/python" if name == "python" else None
|
||||
|
||||
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which", fake_which
|
||||
)
|
||||
@@ -369,12 +373,79 @@ class TestResolvePythonInterpreter:
|
||||
|
||||
def test_ignores_missing_venv(self, monkeypatch, tmp_path):
|
||||
# Negative: no venv directory -> PATH resolution is used instead.
|
||||
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which",
|
||||
lambda name: "/usr/bin/python3" if name == "python3" else None,
|
||||
)
|
||||
assert IntegrationBase.resolve_python_interpreter(tmp_path) == "python3"
|
||||
|
||||
def test_windows_skips_store_alias_stub(self, monkeypatch):
|
||||
# On Windows, python3 on PATH may be the Microsoft Store App
|
||||
# Execution Alias stub: it exists but only prints an installer
|
||||
# hint and exits non-zero. Existence is not enough; the
|
||||
# interpreter must actually run (mirrors #3304 for the CLI).
|
||||
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "win32")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which",
|
||||
lambda name: f"C:\\WindowsApps\\{name}.exe"
|
||||
if name in ("python3", "python")
|
||||
else None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
IntegrationBase, "_interpreter_runs", staticmethod(lambda path: False)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.sys.executable", "C:\\Python\\python.exe"
|
||||
)
|
||||
result = IntegrationBase.resolve_python_interpreter()
|
||||
assert result == "C:\\Python\\python.exe"
|
||||
|
||||
def test_windows_keeps_working_interpreter(self, monkeypatch):
|
||||
# Positive: a real python3 on Windows PATH passes the run check.
|
||||
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "win32")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which",
|
||||
lambda name: f"C:\\Python\\{name}.exe" if name == "python3" else None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
IntegrationBase, "_interpreter_runs", staticmethod(lambda path: True)
|
||||
)
|
||||
assert IntegrationBase.resolve_python_interpreter() == "python3"
|
||||
|
||||
def test_windows_stub_python3_falls_through_to_working_python(self, monkeypatch):
|
||||
# python3 is the stub but python is a real install: pick python.
|
||||
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "win32")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which",
|
||||
lambda name: f"C:\\somewhere\\{name}.exe"
|
||||
if name in ("python3", "python")
|
||||
else None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
IntegrationBase,
|
||||
"_interpreter_runs",
|
||||
staticmethod(lambda path: path.endswith("python.exe")),
|
||||
)
|
||||
assert IntegrationBase.resolve_python_interpreter() == "python"
|
||||
|
||||
def test_posix_does_not_spawn_run_check(self, monkeypatch):
|
||||
# Non-Windows platforms have no App Execution Alias; existence
|
||||
# on PATH stays sufficient and no subprocess is spawned.
|
||||
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which",
|
||||
lambda name: "/usr/bin/python3" if name == "python3" else None,
|
||||
)
|
||||
|
||||
def boom(path):
|
||||
raise AssertionError("run check must not execute on POSIX")
|
||||
|
||||
monkeypatch.setattr(
|
||||
IntegrationBase, "_interpreter_runs", staticmethod(boom)
|
||||
)
|
||||
assert IntegrationBase.resolve_python_interpreter() == "python3"
|
||||
|
||||
|
||||
class TestProcessTemplatePyScriptType:
|
||||
CONTENT = (
|
||||
@@ -390,6 +461,7 @@ class TestProcessTemplatePyScriptType:
|
||||
def test_py_prefixes_interpreter(self, monkeypatch):
|
||||
# Positive: py script type prefixes a resolved interpreter and the
|
||||
# script path is rewritten to the .specify location.
|
||||
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which",
|
||||
lambda name: "/usr/bin/python3" if name == "python3" else None,
|
||||
|
||||
@@ -2073,3 +2073,44 @@ class TestIntegrationCatalogDiscoveryCLI:
|
||||
assert listing.exit_code == 0, listing.output
|
||||
assert "default" in listing.output
|
||||
assert "community" in listing.output
|
||||
|
||||
|
||||
def test_refresh_shared_templates_preserves_recovered_user_file(tmp_path):
|
||||
"""refresh_shared_templates must not overwrite a recovered (pre-existing
|
||||
user) template without --force, matching install_shared_infra's gate (#2918).
|
||||
"""
|
||||
from specify_cli.shared_infra import (
|
||||
load_speckit_manifest,
|
||||
refresh_shared_templates,
|
||||
)
|
||||
|
||||
project = tmp_path / "proj"
|
||||
templates_dir = project / ".specify" / "templates"
|
||||
templates_dir.mkdir(parents=True)
|
||||
user_file = templates_dir / "spec-template.md"
|
||||
user_file.write_text("# USER CUSTOM CONTENT\n", encoding="utf-8")
|
||||
|
||||
# Record the pre-existing file as recovered (its hash was adopted, not written).
|
||||
manifest = load_speckit_manifest(project, version="test", console=_NoopConsole())
|
||||
rel = ".specify/templates/spec-template.md"
|
||||
manifest.record_existing(rel, recovered=True)
|
||||
manifest.save()
|
||||
|
||||
# Bundled source ships a different body for the same template.
|
||||
core_pack = tmp_path / "core-pack"
|
||||
src = core_pack / "templates"
|
||||
src.mkdir(parents=True)
|
||||
(src / "spec-template.md").write_text("# BUNDLED CONTENT v2\n", encoding="utf-8")
|
||||
|
||||
refresh_shared_templates(
|
||||
project,
|
||||
version="test",
|
||||
core_pack=core_pack,
|
||||
repo_root=tmp_path / "unused",
|
||||
console=_NoopConsole(),
|
||||
invoke_separator=".",
|
||||
force=False,
|
||||
)
|
||||
|
||||
# Recovered user content must survive (fail-before: replaced by bundled body).
|
||||
assert user_file.read_text(encoding="utf-8") == "# USER CUSTOM CONTENT\n"
|
||||
|
||||
@@ -15,6 +15,10 @@ def test_integration_tests_use_tmp_home(tmp_path: Path) -> None:
|
||||
assert Path(os.environ["XDG_CONFIG_HOME"]) == home / ".config"
|
||||
assert Path(os.environ["XDG_DATA_HOME"]) == home / ".local" / "share"
|
||||
|
||||
# Most integrations resolve the user home via Path.home() (e.g. Hermes,
|
||||
# catalog), so the isolation has to reach that API, not just the env vars.
|
||||
assert Path.home() == home
|
||||
|
||||
assert home.is_dir()
|
||||
assert (home / ".cache").is_dir()
|
||||
assert (home / ".config").is_dir()
|
||||
|
||||
@@ -291,6 +291,18 @@ class TomlIntegrationTests:
|
||||
"closing delimiter should be inline when body does not end with a quote"
|
||||
)
|
||||
|
||||
def test_toml_string_escapes_control_characters(self):
|
||||
"""A value with control chars / a bare CR must render as parseable TOML.
|
||||
|
||||
TOML forbids literal control characters (U+0000–U+001F except tab and
|
||||
newline, plus U+007F) in every string form, and a bare CR that is not
|
||||
part of a CRLF pair. The renderer used to emit these raw into a basic or
|
||||
``\"\"\"`` multiline string, producing a config file that fails to parse."""
|
||||
value = "start\x00null\x01ctrl\x1besc\x7fdel\rlone-cr end"
|
||||
rendered = TomlIntegration._render_toml_string(value)
|
||||
parsed = tomllib.loads(f"prompt = {rendered}")
|
||||
assert parsed["prompt"] == value
|
||||
|
||||
def test_toml_is_valid(self, tmp_path):
|
||||
"""Every generated TOML file must parse without errors."""
|
||||
i = get_integration(self.KEY)
|
||||
|
||||
@@ -84,6 +84,20 @@ 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,4 +231,3 @@ 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, (
|
||||
|
||||
@@ -36,3 +36,50 @@ class TestGooseIntegration(YamlIntegrationTests):
|
||||
param.get("key") == "args"
|
||||
for param in data.get("parameters", [])
|
||||
), f"{recipe_file} uses {{{{args}}}} but does not declare args"
|
||||
|
||||
|
||||
class TestGooseCommandPlaceholderResolution:
|
||||
"""register_commands must resolve skill placeholders for the yaml branch.
|
||||
|
||||
The yaml (Goose recipe) branch previously skipped
|
||||
resolve_skill_placeholders / _convert_argument_placeholder that the
|
||||
markdown and toml branches apply, so extension/preset command bodies
|
||||
kept literal {SCRIPT} / __AGENT__ / repo-relative paths.
|
||||
"""
|
||||
|
||||
def test_register_commands_resolves_placeholders_in_recipe(self, tmp_path):
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
ext_dir = tmp_path / "extension"
|
||||
cmd_dir = ext_dir / "commands"
|
||||
cmd_dir.mkdir(parents=True)
|
||||
cmd_file = cmd_dir / "example.md"
|
||||
cmd_file.write_text(
|
||||
"---\n"
|
||||
"description: Placeholder command\n"
|
||||
"scripts:\n"
|
||||
" sh: scripts/bash/do.sh\n"
|
||||
" ps: scripts/powershell/do.ps1\n"
|
||||
"---\n\n"
|
||||
"Run {SCRIPT} for agent __AGENT__ with $ARGUMENTS.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
commands = [{"name": "speckit.example", "file": "commands/example.md"}]
|
||||
registrar.register_commands("goose", commands, "test-ext", ext_dir, tmp_path)
|
||||
|
||||
recipe = tmp_path / ".goose" / "recipes" / "speckit.example.yaml"
|
||||
assert recipe.exists(), "goose recipe should be generated"
|
||||
# Parse the recipe and assert the prompt actually got the correct
|
||||
# replacements — not merely that the literal tokens are absent (which
|
||||
# a wrong-but-token-free output could also satisfy).
|
||||
data = yaml.safe_load(recipe.read_text(encoding="utf-8"))
|
||||
prompt = data["prompt"]
|
||||
assert ".specify/scripts/" in prompt # {SCRIPT} -> resolved script path
|
||||
assert "agent goose" in prompt # __AGENT__ -> agent name
|
||||
assert "{{args}}" in prompt # $ARGUMENTS -> goose args token
|
||||
# And the raw placeholders must not survive.
|
||||
assert "{SCRIPT}" not in prompt
|
||||
assert "__AGENT__" not in prompt
|
||||
assert "$ARGUMENTS" not in prompt
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -481,3 +481,40 @@ class TestRecordExistingNewGuards:
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
with pytest.raises(ValueError, match=r"canonical|'\.\.' segments"):
|
||||
m.record_existing("dir/../file.txt")
|
||||
|
||||
|
||||
class TestManifestUnreadableFile:
|
||||
"""A managed file that is unreadable (e.g. PermissionError) must not crash
|
||||
check_modified()/uninstall() — the CLI handlers surfaced a raw traceback."""
|
||||
|
||||
def _mk(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_file("sub/f.md", "content")
|
||||
return m
|
||||
|
||||
def test_check_modified_treats_unreadable_as_modified(self, tmp_path, monkeypatch):
|
||||
m = self._mk(tmp_path)
|
||||
|
||||
def raise_perm(_path):
|
||||
raise PermissionError("unreadable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.manifest._sha256", raise_perm
|
||||
)
|
||||
# Before the fix this raised PermissionError.
|
||||
assert m.check_modified() == ["sub/f.md"]
|
||||
|
||||
def test_uninstall_preserves_unreadable_file(self, tmp_path, monkeypatch):
|
||||
m = self._mk(tmp_path)
|
||||
|
||||
def raise_perm(_path):
|
||||
raise PermissionError("unreadable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.manifest._sha256", raise_perm
|
||||
)
|
||||
removed, skipped = m.uninstall(force=False)
|
||||
# Can't verify ownership => preserve, don't crash and don't delete.
|
||||
assert removed == []
|
||||
assert (tmp_path / "sub" / "f.md") in skipped
|
||||
assert (tmp_path / "sub" / "f.md").exists()
|
||||
|
||||
111
tests/integrations/test_skill_frontmatter_quoting.py
Normal file
111
tests/integrations/test_skill_frontmatter_quoting.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Regression tests for SKILL.md frontmatter quoting (#3391).
|
||||
|
||||
The skills setup path builds SKILL.md frontmatter by hand with
|
||||
double-quoted values. A double-quoted YAML scalar cannot carry a raw
|
||||
newline (the parser folds it to a space) or a control character (the
|
||||
reader rejects the document), so descriptions taken from template
|
||||
frontmatter must be escaped by the YAML emitter.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.base import yaml_quote
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
MULTILINE = "first line\nsecond line\n"
|
||||
CONTROL = "ding\aling"
|
||||
|
||||
HOSTILE_TEMPLATE = """---
|
||||
description: |
|
||||
first line
|
||||
second line
|
||||
---
|
||||
|
||||
Body of the command.
|
||||
"""
|
||||
|
||||
CONTROL_TEMPLATE = """---
|
||||
description: "ding\\aling"
|
||||
---
|
||||
|
||||
Body of the command.
|
||||
"""
|
||||
|
||||
|
||||
def _parse_frontmatter(skill_file: Path) -> dict:
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
assert content.startswith("---\n")
|
||||
return yaml.safe_load(content.split("---", 2)[1])
|
||||
|
||||
|
||||
def _fake_templates(tmp_path: Path, body: str) -> Path:
|
||||
templates = tmp_path / "templates"
|
||||
templates.mkdir(exist_ok=True)
|
||||
(templates / "plan.md").write_text(body, encoding="utf-8")
|
||||
return templates
|
||||
|
||||
|
||||
class TestYamlQuote:
|
||||
def test_simple_value_keeps_plain_double_quoted_form(self):
|
||||
assert yaml_quote("speckit-plan") == '"speckit-plan"'
|
||||
assert yaml_quote('say "hi"') == '"say \\"hi\\""'
|
||||
assert yaml_quote("back\\slash") == '"back\\\\slash"'
|
||||
|
||||
def test_multiline_value_round_trips(self):
|
||||
quoted = yaml_quote(MULTILINE)
|
||||
assert "\n" not in quoted
|
||||
assert yaml.safe_load(quoted) == MULTILINE
|
||||
|
||||
def test_control_character_round_trips(self):
|
||||
quoted = yaml_quote(CONTROL)
|
||||
assert "\a" not in quoted
|
||||
assert yaml.safe_load(quoted) == CONTROL
|
||||
|
||||
|
||||
class TestSkillFrontmatterQuoting:
|
||||
def _generate(self, tmp_path, monkeypatch, template: str) -> Path:
|
||||
integration = get_integration("agy")
|
||||
monkeypatch.setattr(
|
||||
integration,
|
||||
"shared_commands_dir",
|
||||
lambda: _fake_templates(tmp_path, template),
|
||||
)
|
||||
manifest = IntegrationManifest("agy", tmp_path)
|
||||
created = integration.setup(tmp_path, manifest)
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert len(skill_files) == 1
|
||||
return skill_files[0]
|
||||
|
||||
def test_multiline_description_survives(self, tmp_path, monkeypatch):
|
||||
skill_file = self._generate(tmp_path, monkeypatch, HOSTILE_TEMPLATE)
|
||||
fm = _parse_frontmatter(skill_file)
|
||||
assert fm["description"] == MULTILINE
|
||||
|
||||
def test_control_character_description_parses(self, tmp_path, monkeypatch):
|
||||
skill_file = self._generate(tmp_path, monkeypatch, CONTROL_TEMPLATE)
|
||||
fm = _parse_frontmatter(skill_file)
|
||||
assert fm["description"] == CONTROL
|
||||
|
||||
|
||||
class TestHermesSkillFrontmatterQuoting:
|
||||
def test_multiline_description_survives(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir(exist_ok=True)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
integration = get_integration("hermes")
|
||||
monkeypatch.setattr(
|
||||
integration,
|
||||
"shared_commands_dir",
|
||||
lambda: _fake_templates(tmp_path, HOSTILE_TEMPLATE),
|
||||
)
|
||||
manifest = IntegrationManifest("hermes", tmp_path)
|
||||
created = integration.setup(tmp_path, manifest)
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert len(skill_files) == 1
|
||||
|
||||
fm = _parse_frontmatter(skill_files[0])
|
||||
assert fm["description"] == MULTILINE
|
||||
@@ -315,6 +315,20 @@ 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", []) == []
|
||||
|
||||
@@ -845,6 +859,22 @@ class TestRedirectStripping:
|
||||
auth3 = req3.get_header("Authorization") or req3.unredirected_hdrs.get("Authorization")
|
||||
assert auth3 == "Bearer tok"
|
||||
|
||||
def test_malformed_redirect_url_raises_urlerror_not_valueerror(self):
|
||||
"""A redirect to a malformed URL (unterminated IPv6 bracket) surfaces
|
||||
as URLError, which download paths already handle, rather than an
|
||||
unhandled ValueError traceback."""
|
||||
import urllib.error
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
|
||||
handler = _StripAuthOnRedirect(("github.com",))
|
||||
req = Request("https://github.com/org/repo")
|
||||
|
||||
with pytest.raises(urllib.error.URLError):
|
||||
handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
|
||||
"https://[::1/asset")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _fetch_latest_release_tag delegation
|
||||
|
||||
107
tests/test_command_template_py_scripts.py
Normal file
107
tests/test_command_template_py_scripts.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1795,6 +1795,25 @@ $ARGUMENTS
|
||||
|
||||
assert parsed["description"] == "first line\nsecond line\n"
|
||||
|
||||
def test_render_toml_command_escapes_control_characters(self):
|
||||
"""Control characters and a lone CR must be escaped so the TOML parses.
|
||||
|
||||
TOML forbids literal control characters (U+0000–U+001F except tab and
|
||||
newline, plus U+007F) in any string, and treats a bare CR outside a
|
||||
CRLF pair as illegal. The renderer used to emit these raw — into a
|
||||
basic string (single-line) or a ``\"\"\"`` multiline string (for a lone
|
||||
CR) — producing a command file that fails to parse."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
|
||||
registrar = AgentCommandRegistrar()
|
||||
body = "start\x00null\x01ctrl\x1besc\x7fdel\rlone-cr end"
|
||||
output = registrar.render_toml_command(
|
||||
{"description": "d"}, body, "extension:test-ext"
|
||||
)
|
||||
|
||||
parsed = tomllib.loads(output)
|
||||
assert parsed["prompt"] == body
|
||||
|
||||
def test_render_toml_command_preserves_backslashes_in_body(self):
|
||||
"""A backslash in the body (e.g. a Windows path) must not break TOML.
|
||||
|
||||
@@ -5441,6 +5460,29 @@ class TestExtensionAddCLI:
|
||||
f"confirm must precede spinner, got: {call_order}"
|
||||
assert result.exit_code == 0 # user declined → clean exit
|
||||
|
||||
def test_add_from_malformed_ipv6_url_exits_cleanly(self, tmp_path):
|
||||
"""A malformed IPv6 URL must produce a clean error, not a ValueError traceback."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
project_dir = tmp_path / "test-project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["extension", "add", "my-ext", "--from", "https://[::1/ext.zip"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
plain = strip_ansi(result.output)
|
||||
assert "Invalid URL" in plain
|
||||
|
||||
def test_add_status_escapes_extension_markup(self, tmp_path):
|
||||
"""User-controlled extension names must not be parsed as Rich markup."""
|
||||
from rich.markup import escape as escape_markup
|
||||
|
||||
@@ -233,6 +233,23 @@ class TestResolveGitHubReleaseAssetApiUrl:
|
||||
assert result is None
|
||||
assert called == []
|
||||
|
||||
def test_returns_none_on_malformed_ghes_port(self):
|
||||
"""A malformed port on an allowlisted GHES host returns None, not a
|
||||
ValueError (contract: resolve or return None, never raise)."""
|
||||
called = []
|
||||
|
||||
def open_never(url, timeout=None, extra_headers=None):
|
||||
called.append(url)
|
||||
raise AssertionError("open_url_fn must not be called")
|
||||
|
||||
result = resolve_github_release_asset_api_url(
|
||||
"https://ghes.example:notaport/o/r/releases/download/v1/ext.zip",
|
||||
open_never,
|
||||
github_hosts=("ghes.example",),
|
||||
)
|
||||
assert result is None
|
||||
assert called == []
|
||||
|
||||
def test_passthrough_for_unlisted_ghes_api_asset_url(self):
|
||||
"""A direct GHES /api/v3 asset URL passes through even when the host is
|
||||
not allowlisted: passthrough issues no API request, and the download
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -4538,6 +4538,27 @@ class TestBundledPresetLocator:
|
||||
assert "got https://" not in output
|
||||
open_url.assert_not_called()
|
||||
|
||||
def test_preset_add_from_malformed_ipv6_url_exits_cleanly(self, project_dir):
|
||||
"""A malformed IPv6 URL must produce a clean error, not a ValueError traceback."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("specify_cli.authentication.http.open_url") as open_url:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["preset", "add", "--from", "https://[::1/preset.zip"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
output = strip_ansi(result.output)
|
||||
assert "Invalid URL" in output
|
||||
open_url.assert_not_called()
|
||||
|
||||
def test_preset_add_from_url_redirect_error_describes_disallowed_url(self, project_dir, monkeypatch, capsys):
|
||||
"""Redirect rejection message covers hostless HTTPS, not only non-HTTPS URLs."""
|
||||
import typer
|
||||
|
||||
@@ -126,6 +126,71 @@ def test_setup_plan_errors_without_feature_context(plan_repo: Path) -> None:
|
||||
assert "Feature directory not found" in result.stderr
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_plan_survives_broken_python3_stub(plan_repo: Path) -> None:
|
||||
"""A `python3` on PATH that exists but fails at runtime must not defeat
|
||||
feature.json parsing.
|
||||
|
||||
On Windows `python3` typically resolves to the Microsoft Store App Execution
|
||||
Alias stub: it satisfies `command -v python3` yet exits non-zero at runtime.
|
||||
The parser must fall through to the grep/sed fallback on that failure instead
|
||||
of selecting python3 by mere availability and swallowing its error (#3304).
|
||||
"""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "feature/my-feature-branch"],
|
||||
cwd=plan_repo,
|
||||
check=True,
|
||||
)
|
||||
feat = plan_repo / "specs" / "001-tiny-notes-app"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
_write_feature_json(plan_repo, "specs/001-tiny-notes-app")
|
||||
|
||||
# A stub python3 that mimics the Windows Store alias: on PATH, exits 49.
|
||||
stub_dir = plan_repo / "_stubbin"
|
||||
stub_dir.mkdir()
|
||||
stub = stub_dir / "python3"
|
||||
stub.write_text(
|
||||
"#!/bin/sh\n"
|
||||
'echo "Python was not found; run without arguments to install from the '
|
||||
'Microsoft Store" >&2\n'
|
||||
"exit 49\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
stub.chmod(0o755)
|
||||
|
||||
# A stub jq that shadows any real jq on PATH and also fails, so the parser
|
||||
# cannot short-circuit on jq and must reach the broken python3 stub and then
|
||||
# fall through to grep/sed. Without this, a runner that has jq installed
|
||||
# would parse feature.json via jq and never exercise the fallback this test
|
||||
# is meant to cover.
|
||||
jq_stub = stub_dir / "jq"
|
||||
jq_stub.write_text(
|
||||
"#!/bin/sh\n"
|
||||
'echo "jq: simulated failure" >&2\n'
|
||||
"exit 1\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
jq_stub.chmod(0o755)
|
||||
|
||||
env = _clean_env()
|
||||
# Prepend the stub dir so the failing jq and python3 stubs take precedence
|
||||
# over any real ones; PATH still needs the real bash utilities for grep/sed.
|
||||
env["PATH"] = f"{stub_dir}{os.pathsep}{env.get('PATH', '')}"
|
||||
|
||||
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
|
||||
result = subprocess.run(
|
||||
["bash", str(script)],
|
||||
cwd=plan_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
assert (feat / "plan.md").is_file()
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_plan_numbered_branch_works_with_feature_json(
|
||||
plan_repo: Path,
|
||||
|
||||
@@ -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"
|
||||
@@ -466,6 +466,68 @@ def test_bash_command_hint_preserves_hyphens_inside_segments(tasks_repo: Path) -
|
||||
assert result.stdout.strip() == "/speckit.jira.sync-status"
|
||||
|
||||
|
||||
def _install_broken_json_tool_stubs(repo: Path) -> Path:
|
||||
"""Create a bin dir with `jq` and `python3` stubs that exist but fail.
|
||||
|
||||
Mimics stock Windows + Git Bash, where a JSON tool may be missing or broken
|
||||
and `python3` resolves to the Microsoft Store App Execution Alias stub: both
|
||||
satisfy `command -v` yet fail at runtime (the alias exits 49). Prepending
|
||||
this to PATH forces the invoke-separator parser past jq and python3 to its
|
||||
awk text fallback (#3304).
|
||||
"""
|
||||
stub_dir = repo / "_broken_bin"
|
||||
stub_dir.mkdir(exist_ok=True)
|
||||
for name in ("jq", "python3"):
|
||||
stub = stub_dir / name
|
||||
stub.write_text(
|
||||
"#!/bin/sh\n"
|
||||
'echo "simulated broken interpreter/tool" >&2\n'
|
||||
"exit 49\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
stub.chmod(0o755)
|
||||
return stub_dir
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_bash_command_hint_falls_back_to_awk_when_jq_and_python3_broken(
|
||||
tasks_repo: Path,
|
||||
) -> None:
|
||||
"""Separator resolution survives broken jq and python3 stubs (#3304).
|
||||
|
||||
`get_invoke_separator` historically selected python3 by availability and
|
||||
had no text fallback, so a Windows Store python3 stub made it silently
|
||||
return "." even for `-`-separator integrations (e.g. forge), yielding a
|
||||
wrong hint like `/speckit.plan`. The awk fallback must recover `-`.
|
||||
"""
|
||||
_write_integration_state(tasks_repo, "forge", "-")
|
||||
stub_dir = _install_broken_json_tool_stubs(tasks_repo)
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "common.sh"
|
||||
env = _clean_env()
|
||||
env["PATH"] = f"{stub_dir}{os.pathsep}{env.get('PATH', '')}"
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"bash",
|
||||
"-c",
|
||||
'source "$1"; format_speckit_command "$2" "$PWD"',
|
||||
"bash",
|
||||
str(script),
|
||||
"plan",
|
||||
],
|
||||
cwd=tasks_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "/speckit-plan"
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_bash_command_hint_caches_invoke_separator_per_process(tasks_repo: Path) -> None:
|
||||
_write_integration_state(tasks_repo, "claude", "-")
|
||||
@@ -557,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,
|
||||
@@ -574,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,
|
||||
@@ -601,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:
|
||||
"""
|
||||
@@ -616,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,
|
||||
@@ -625,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:
|
||||
"""
|
||||
@@ -642,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,
|
||||
@@ -659,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"
|
||||
@@ -669,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:
|
||||
"""
|
||||
@@ -678,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,
|
||||
@@ -693,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,
|
||||
@@ -793,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,
|
||||
@@ -811,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,4 +1321,3 @@ 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
|
||||
|
||||
@@ -601,6 +601,73 @@ class TestExpressions:
|
||||
):
|
||||
evaluate_expression("{{ inputs.tags | map }}", ctx)
|
||||
|
||||
def test_chained_filters_apply_left_to_right(self):
|
||||
# Filters chain: each filter's result feeds the next. `map` yields a
|
||||
# list and `join` is the only filter that renders a list to a string,
|
||||
# so `map('name') | join(', ')` is the canonical pairing — it must not
|
||||
# raise. Previously the pipe parser split only at the first `|` and
|
||||
# handed the whole tail (`map('name') | join(', ')`) to one filter,
|
||||
# which the `name(arg)` regex mangled into a ValueError.
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
ctx = StepContext(
|
||||
inputs={
|
||||
"rows": [{"name": "a"}, {"name": "b"}],
|
||||
"tags": ["x", "y"],
|
||||
"missing": None,
|
||||
}
|
||||
)
|
||||
assert (
|
||||
evaluate_expression(
|
||||
"{{ inputs.rows | map('name') | join(', ') }}", ctx
|
||||
)
|
||||
== "a, b"
|
||||
)
|
||||
# A three-link chain: map -> join -> contains.
|
||||
assert (
|
||||
evaluate_expression(
|
||||
"{{ inputs.rows | map('name') | join(', ') | contains('a') }}",
|
||||
ctx,
|
||||
)
|
||||
is True
|
||||
)
|
||||
# default's fallback then flows into the next filter.
|
||||
assert (
|
||||
evaluate_expression(
|
||||
"{{ inputs.missing | default('x') | contains('x') }}", ctx
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_chained_filter_error_in_later_link_raises(self):
|
||||
# A mis-wired filter anywhere in the chain must fail loudly, not just
|
||||
# the first link.
|
||||
import pytest
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
ctx = StepContext(inputs={"rows": [{"name": "a"}]})
|
||||
with pytest.raises(ValueError, match="unknown filter 'bogus'"):
|
||||
evaluate_expression(
|
||||
"{{ inputs.rows | map('name') | bogus }}", ctx
|
||||
)
|
||||
|
||||
def test_pipe_in_quoted_arg_is_not_a_filter_separator(self):
|
||||
# A literal `|` inside a quoted operand or filter argument must not be
|
||||
# mistaken for a filter-chain separator — the top-level split has to
|
||||
# respect quotes.
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
ctx = StepContext(inputs={"mode": "a|b", "tags": ["a|b", "c"]})
|
||||
assert evaluate_expression("{{ inputs.mode == 'a|b' }}", ctx) is True
|
||||
# `|` inside a filter argument stays part of the argument.
|
||||
assert (
|
||||
evaluate_expression("{{ inputs.tags | join(' | ') }}", ctx)
|
||||
== "a|b | c"
|
||||
)
|
||||
|
||||
def test_condition_evaluation(self):
|
||||
from specify_cli.workflows.expressions import evaluate_condition
|
||||
from specify_cli.workflows.base import StepContext
|
||||
@@ -1286,6 +1353,106 @@ class TestShellStep:
|
||||
assert step.validate({"id": "s", "run": "echo hi"}) == []
|
||||
assert step.validate({"id": "s", "run": "{{ steps.x.output }}"}) == []
|
||||
|
||||
def test_timeout_is_configurable(self, monkeypatch):
|
||||
"""A 'timeout' field overrides the 300s default (#3327)."""
|
||||
import subprocess as sp
|
||||
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
seen = {}
|
||||
real_run = sp.run
|
||||
|
||||
def spy_run(*args, **kwargs):
|
||||
seen["timeout"] = kwargs.get("timeout")
|
||||
return real_run(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.workflows.steps.shell.subprocess.run", spy_run
|
||||
)
|
||||
step = ShellStep()
|
||||
result = step.execute(
|
||||
{"id": "t", "run": "echo hi", "timeout": 1800}, StepContext()
|
||||
)
|
||||
assert result.status == StepStatus.COMPLETED
|
||||
assert seen["timeout"] == 1800
|
||||
|
||||
def test_timeout_defaults_to_300(self, monkeypatch):
|
||||
import subprocess as sp
|
||||
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
seen = {}
|
||||
real_run = sp.run
|
||||
|
||||
def spy_run(*args, **kwargs):
|
||||
seen["timeout"] = kwargs.get("timeout")
|
||||
return real_run(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.workflows.steps.shell.subprocess.run", spy_run
|
||||
)
|
||||
result = ShellStep().execute({"id": "t", "run": "echo hi"}, StepContext())
|
||||
assert result.status == StepStatus.COMPLETED
|
||||
assert seen["timeout"] == 300
|
||||
|
||||
def test_timeout_error_reports_configured_value(self, monkeypatch):
|
||||
import subprocess as sp
|
||||
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
def raise_timeout(*args, **kwargs):
|
||||
raise sp.TimeoutExpired(cmd="x", timeout=kwargs.get("timeout"))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.workflows.steps.shell.subprocess.run", raise_timeout
|
||||
)
|
||||
result = ShellStep().execute(
|
||||
{"id": "t", "run": "sleep 999", "timeout": 7}, StepContext()
|
||||
)
|
||||
assert result.status == StepStatus.FAILED
|
||||
assert "7 seconds" in result.error
|
||||
|
||||
@pytest.mark.parametrize("bad", [0, -5, "600", 1.5, None, True])
|
||||
def test_execute_ignores_unvalidated_bad_timeout(self, bad, monkeypatch):
|
||||
"""execute() falls back to 300 when config skipped validation (#3327)."""
|
||||
import subprocess as sp
|
||||
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
seen = {}
|
||||
real_run = sp.run
|
||||
|
||||
def spy_run(*args, **kwargs):
|
||||
seen["timeout"] = kwargs.get("timeout")
|
||||
return real_run(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.workflows.steps.shell.subprocess.run", spy_run
|
||||
)
|
||||
result = ShellStep().execute(
|
||||
{"id": "t", "run": "echo hi", "timeout": bad}, StepContext()
|
||||
)
|
||||
assert result.status == StepStatus.COMPLETED
|
||||
assert seen["timeout"] == 300
|
||||
|
||||
@pytest.mark.parametrize("bad", [0, -5, "600", 1.5, None, True])
|
||||
def test_validate_rejects_bad_timeout(self, bad):
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
|
||||
errors = ShellStep().validate({"id": "s", "run": "echo hi", "timeout": bad})
|
||||
assert any("'timeout'" in e for e in errors)
|
||||
|
||||
def test_validate_accepts_positive_int_timeout(self):
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
|
||||
assert (
|
||||
ShellStep().validate({"id": "s", "run": "echo hi", "timeout": 1800}) == []
|
||||
)
|
||||
|
||||
|
||||
def test_output_format_json_exposes_data(self, tmp_path):
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
@@ -2627,6 +2794,101 @@ 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
|
||||
|
||||
@@ -5593,6 +5855,23 @@ class TestWorkflowRemoveGuard:
|
||||
|
||||
|
||||
class TestWorkflowAddSymlinkGuard:
|
||||
def test_add_malformed_ipv6_url_exits_cleanly(self, temp_dir, monkeypatch):
|
||||
"""A malformed IPv6 URL must produce a clean error, not a ValueError traceback."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
(temp_dir / ".specify").mkdir(exist_ok=True)
|
||||
monkeypatch.chdir(temp_dir)
|
||||
result = CliRunner().invoke(
|
||||
app,
|
||||
["workflow", "add", "https://[::1/wf.yaml"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
assert "Invalid URL" in result.output
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_add_refuses_symlinked_specify(self, temp_dir, monkeypatch):
|
||||
"""workflow add must refuse a symlinked .specify (writes could escape root)."""
|
||||
@@ -6865,3 +7144,53 @@ 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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user