mirror of
https://github.com/github/spec-kit.git
synced 2026-07-10 01:33:05 +08:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
d5ba062eab | ||
|
|
12faf7b5b5 | ||
|
|
220e6fcc4e | ||
|
|
fb796c2a39 | ||
|
|
0151d239b5 | ||
|
|
f764270d06 | ||
|
|
7839acce86 | ||
|
|
1935cf7e48 | ||
|
|
d4e7d2b888 | ||
|
|
abaed10d00 |
45
CHANGELOG.md
45
CHANGELOG.md
@@ -2,6 +2,51 @@
|
|||||||
|
|
||||||
<!-- insert new changelog below this comment -->
|
<!-- insert new changelog below this comment -->
|
||||||
|
|
||||||
|
## [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
|
## [0.12.6] - 2026-07-07
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -406,7 +406,7 @@ specify init . --force --integration copilot
|
|||||||
specify init --here --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
|
```bash
|
||||||
specify init <project_name> --integration copilot --ignore-agent-tools
|
specify init <project_name> --integration copilot --ignore-agent-tools
|
||||||
|
|||||||
@@ -51,14 +51,14 @@ The following community-contributed extensions are available in [`catalog.commun
|
|||||||
| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) |
|
| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) |
|
||||||
| 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) |
|
| 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) |
|
| 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 | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. One pinned runtime dependency; pure Node.js otherwise. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
| 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) |
|
||||||
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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 +67,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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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 — 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) |
|
| 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) |
|
||||||
@@ -86,6 +87,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
|||||||
| .NET Framework to Modern .NET Migration | Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration | `process` | Read+Write | [spec-kit-fx-to-net](https://github.com/RogerBestMsft/spec-kit-FxToNet) |
|
| .NET Framework to Modern .NET Migration | Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration | `process` | Read+Write | [spec-kit-fx-to-net](https://github.com/RogerBestMsft/spec-kit-FxToNet) |
|
||||||
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
|
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
|
||||||
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
|
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
|
||||||
|
| Orchestration Task Context Management | Adds subagent work-unit orchestration to generated Spec Kit task files | `process` | Read+Write | [spec-kit-orchestration-task-context-management](https://github.com/benizzio/spec-kit-orchestration-task-context-management) |
|
||||||
| OWASP LLM Threat Model | OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts | `code` | Read-only | [spec-kit-threatmodel](https://github.com/NaviaSamal/spec-kit-threatmodel) |
|
| OWASP LLM Threat Model | OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts | `code` | Read-only | [spec-kit-threatmodel](https://github.com/NaviaSamal/spec-kit-threatmodel) |
|
||||||
| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
|
| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
|
||||||
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
|
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
|
||||||
@@ -106,7 +108,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
|||||||
| Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) |
|
| Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) |
|
||||||
| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |
|
| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |
|
||||||
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
|
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
|
||||||
| Ripple | Detect side effects that tests can't catch after implementation — delta-anchored analysis across 9 domain-agnostic categories | `code` | Read+Write | [spec-kit-ripple](https://github.com/chordpli/spec-kit-ripple) |
|
| Ripple | Detect side effects that tests can't catch after implementation — surface hidden ripple effects across 9 analysis categories | `code` | Read+Write | [spec-kit-ripple](https://github.com/chordpli/spec-kit-ripple) |
|
||||||
| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) |
|
| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) |
|
||||||
| Security Review | Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews | `code` | Read+Write | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) |
|
| Security Review | Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews | `code` | Read+Write | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) |
|
||||||
| SFSpeckit | Enterprise Salesforce SDLC with 18 commands for the full SDD lifecycle. | `process` | Read+Write | [spec-kit-sf](https://github.com/ysumanth06/spec-kit-sf) |
|
| SFSpeckit | Enterprise Salesforce SDLC with 18 commands for the full SDD lifecycle. | `process` | Read+Write | [spec-kit-sf](https://github.com/ysumanth06/spec-kit-sf) |
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ specify extension add <name>
|
|||||||
| --------------- | -------------------------------------------------------- |
|
| --------------- | -------------------------------------------------------- |
|
||||||
| `--dev` | Install from a local directory (for development) |
|
| `--dev` | Install from a local directory (for development) |
|
||||||
| `--from <url>` | Install from a custom URL instead of the catalog |
|
| `--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) |
|
| `--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.
|
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_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.
|
- `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 (`specs/**/plan.md`, any depth).
|
||||||
|
|
||||||
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.
|
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]`
|
- **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]`
|
- **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` (any depth, so scoped layouts like `specs/<scope>/<feature>/plan.md` are found).
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
#
|
#
|
||||||
# When `plan_path` is omitted, the script derives it from `.specify/feature.json`
|
# When `plan_path` is omitted, the script derives it from `.specify/feature.json`
|
||||||
# (written by /speckit-specify). Falls back to the most recently modified
|
# (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
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -307,14 +307,14 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
root = Path(sys.argv[1]).resolve()
|
root = Path(sys.argv[1]).resolve()
|
||||||
specs = root / "specs"
|
specs = root / "specs"
|
||||||
plans = sorted(
|
plan = max(
|
||||||
specs.glob("*/plan.md"),
|
specs.glob("**/plan.md"),
|
||||||
key=lambda p: p.stat().st_mtime,
|
key=lambda p: p.stat().st_mtime,
|
||||||
reverse=True,
|
default=None,
|
||||||
)
|
)
|
||||||
if plans:
|
if plan:
|
||||||
try:
|
try:
|
||||||
print(plans[0].relative_to(root).as_posix())
|
print(plan.relative_to(root).as_posix())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
print("")
|
print("")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
#
|
#
|
||||||
# When `plan_path` is omitted, the script derives it from `.specify/feature.json`
|
# When `plan_path` is omitted, the script derives it from `.specify/feature.json`
|
||||||
# (written by /speckit-specify). Falls back to the most recently modified
|
# (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()]
|
[CmdletBinding()]
|
||||||
param(
|
param(
|
||||||
@@ -426,9 +426,7 @@ if (-not $PlanPath) {
|
|||||||
if (-not $PlanPath) {
|
if (-not $PlanPath) {
|
||||||
try {
|
try {
|
||||||
$specsDir = Join-Path $ProjectRoot 'specs'
|
$specsDir = Join-Path $ProjectRoot 'specs'
|
||||||
$candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue |
|
$candidate = Get-ChildItem -Path $specsDir -Recurse -File -Filter 'plan.md' -ErrorAction SilentlyContinue |
|
||||||
ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } |
|
|
||||||
Where-Object { $_ } |
|
|
||||||
Sort-Object LastWriteTime -Descending |
|
Sort-Object LastWriteTime -Descending |
|
||||||
Select-Object -First 1
|
Select-Object -First 1
|
||||||
if ($candidate) {
|
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",
|
"schema_version": "1.0",
|
||||||
"updated_at": "2026-07-06T00:00:00Z",
|
"updated_at": "2026-07-07T00:00:00Z",
|
||||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||||
"extensions": {
|
"extensions": {
|
||||||
"aide": {
|
"aide": {
|
||||||
@@ -1106,10 +1106,10 @@
|
|||||||
"docguard": {
|
"docguard": {
|
||||||
"name": "DocGuard — CDD Enforcement",
|
"name": "DocGuard — CDD Enforcement",
|
||||||
"id": "docguard",
|
"id": "docguard",
|
||||||
"description": "Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. One pinned runtime dependency; pure Node.js otherwise.",
|
"description": "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.",
|
||||||
"author": "raccioly",
|
"author": "raccioly",
|
||||||
"version": "0.28.0",
|
"version": "0.30.0",
|
||||||
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.28.0/spec-kit-docguard-v0.28.0.zip",
|
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.30.0/spec-kit-docguard-v0.30.0.zip",
|
||||||
"repository": "https://github.com/raccioly/docguard",
|
"repository": "https://github.com/raccioly/docguard",
|
||||||
"homepage": "https://www.npmjs.com/package/docguard-cli",
|
"homepage": "https://www.npmjs.com/package/docguard-cli",
|
||||||
"documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md",
|
"documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md",
|
||||||
@@ -1145,7 +1145,7 @@
|
|||||||
"downloads": 0,
|
"downloads": 0,
|
||||||
"stars": 0,
|
"stars": 0,
|
||||||
"created_at": "2026-03-13T00:00:00Z",
|
"created_at": "2026-03-13T00:00:00Z",
|
||||||
"updated_at": "2026-06-23T00:00:00Z"
|
"updated_at": "2026-07-06T00:00:00Z"
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
"name": "Project Health Check",
|
"name": "Project Health Check",
|
||||||
@@ -1398,10 +1398,10 @@
|
|||||||
"golden-demo": {
|
"golden-demo": {
|
||||||
"name": "Golden Demo",
|
"name": "Golden Demo",
|
||||||
"id": "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",
|
"author": "jasstt",
|
||||||
"version": "0.1.1",
|
"version": "0.3.0",
|
||||||
"download_url": "https://github.com/jasstt/spec-kit-golden-demo/archive/refs/tags/v0.1.1.zip",
|
"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",
|
"repository": "https://github.com/jasstt/spec-kit-golden-demo",
|
||||||
"homepage": "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",
|
"documentation": "https://github.com/jasstt/spec-kit-golden-demo",
|
||||||
@@ -1412,13 +1412,16 @@
|
|||||||
"speckit_version": ">=0.1.0"
|
"speckit_version": ">=0.1.0"
|
||||||
},
|
},
|
||||||
"provides": {
|
"provides": {
|
||||||
"commands": 2,
|
"commands": 3,
|
||||||
"hooks": 2
|
"hooks": 2
|
||||||
},
|
},
|
||||||
"tags": [
|
"tags": [
|
||||||
"testing",
|
"testing",
|
||||||
"drift-detection",
|
"drift-detection",
|
||||||
"behavioral-oracle",
|
"behavioral-oracle",
|
||||||
|
"fuzzing",
|
||||||
|
"ci-cd",
|
||||||
|
"cross-language",
|
||||||
"tdd",
|
"tdd",
|
||||||
"quality"
|
"quality"
|
||||||
],
|
],
|
||||||
@@ -1426,7 +1429,7 @@
|
|||||||
"downloads": 0,
|
"downloads": 0,
|
||||||
"stars": 0,
|
"stars": 0,
|
||||||
"created_at": "2026-06-24T00:00:00Z",
|
"created_at": "2026-06-24T00:00:00Z",
|
||||||
"updated_at": "2026-06-24T00:00:00Z"
|
"updated_at": "2026-07-07T00:00:00Z"
|
||||||
},
|
},
|
||||||
"harness": {
|
"harness": {
|
||||||
"name": "Research Harness",
|
"name": "Research Harness",
|
||||||
@@ -2396,6 +2399,39 @@
|
|||||||
"created_at": "2026-04-03T00:00:00Z",
|
"created_at": "2026-04-03T00:00:00Z",
|
||||||
"updated_at": "2026-04-03T00:00:00Z"
|
"updated_at": "2026-04-03T00:00:00Z"
|
||||||
},
|
},
|
||||||
|
"orchestration-task-context-management": {
|
||||||
|
"name": "Orchestration Task Context Management",
|
||||||
|
"id": "orchestration-task-context-management",
|
||||||
|
"description": "Adds subagent work-unit orchestration to generated Spec Kit task files",
|
||||||
|
"author": "Igor Benicio de Mesquita",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"download_url": "https://github.com/benizzio/spec-kit-orchestration-task-context-management/archive/refs/tags/v0.0.0.zip",
|
||||||
|
"repository": "https://github.com/benizzio/spec-kit-orchestration-task-context-management",
|
||||||
|
"homepage": "https://github.com/benizzio/spec-kit-orchestration-task-context-management",
|
||||||
|
"documentation": "https://github.com/benizzio/spec-kit-orchestration-task-context-management/blob/main/README.md",
|
||||||
|
"changelog": "https://github.com/benizzio/spec-kit-orchestration-task-context-management/blob/main/CHANGELOG.md",
|
||||||
|
"license": "MIT",
|
||||||
|
"category": "process",
|
||||||
|
"effect": "read-write",
|
||||||
|
"requires": {
|
||||||
|
"speckit_version": ">=0.7.2"
|
||||||
|
},
|
||||||
|
"provides": {
|
||||||
|
"commands": 2,
|
||||||
|
"hooks": 2
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"agent",
|
||||||
|
"orchestration",
|
||||||
|
"tasks",
|
||||||
|
"context"
|
||||||
|
],
|
||||||
|
"verified": false,
|
||||||
|
"downloads": 0,
|
||||||
|
"stars": 0,
|
||||||
|
"created_at": "2026-07-06T00:00:00Z",
|
||||||
|
"updated_at": "2026-07-06T00:00:00Z"
|
||||||
|
},
|
||||||
"orchestrator": {
|
"orchestrator": {
|
||||||
"name": "Spec Orchestrator",
|
"name": "Spec Orchestrator",
|
||||||
"id": "orchestrator",
|
"id": "orchestrator",
|
||||||
@@ -3086,10 +3122,10 @@
|
|||||||
"ripple": {
|
"ripple": {
|
||||||
"name": "Ripple",
|
"name": "Ripple",
|
||||||
"id": "ripple",
|
"id": "ripple",
|
||||||
"description": "Detect side effects that tests can't catch after implementation — delta-anchored analysis across 9 domain-agnostic categories with fix-induced side effect detection",
|
"description": "Detect side effects that tests can't catch after implementation — surface hidden ripple effects across 9 analysis categories",
|
||||||
"author": "chordpli",
|
"author": "chordpli",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"download_url": "https://github.com/chordpli/spec-kit-ripple/archive/refs/tags/v1.0.0.zip",
|
"download_url": "https://github.com/chordpli/spec-kit-ripple/archive/refs/tags/v1.1.0.zip",
|
||||||
"repository": "https://github.com/chordpli/spec-kit-ripple",
|
"repository": "https://github.com/chordpli/spec-kit-ripple",
|
||||||
"homepage": "https://github.com/chordpli/spec-kit-ripple",
|
"homepage": "https://github.com/chordpli/spec-kit-ripple",
|
||||||
"documentation": "https://github.com/chordpli/spec-kit-ripple/blob/main/README.md",
|
"documentation": "https://github.com/chordpli/spec-kit-ripple/blob/main/README.md",
|
||||||
@@ -3098,7 +3134,13 @@
|
|||||||
"category": "code",
|
"category": "code",
|
||||||
"effect": "read-write",
|
"effect": "read-write",
|
||||||
"requires": {
|
"requires": {
|
||||||
"speckit_version": ">=0.2.0"
|
"speckit_version": ">=0.2.0",
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "git",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"provides": {
|
"provides": {
|
||||||
"commands": 3,
|
"commands": 3,
|
||||||
@@ -3115,7 +3157,7 @@
|
|||||||
"downloads": 0,
|
"downloads": 0,
|
||||||
"stars": 0,
|
"stars": 0,
|
||||||
"created_at": "2026-04-20T00:00:00Z",
|
"created_at": "2026-04-20T00:00:00Z",
|
||||||
"updated_at": "2026-04-20T00:00:00Z"
|
"updated_at": "2026-07-06T00:00:00Z"
|
||||||
},
|
},
|
||||||
"roadmap": {
|
"roadmap": {
|
||||||
"name": "Spec Roadmap",
|
"name": "Spec Roadmap",
|
||||||
@@ -4336,6 +4378,40 @@
|
|||||||
"created_at": "2026-04-13T00:00:00Z",
|
"created_at": "2026-04-13T00:00:00Z",
|
||||||
"updated_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": {
|
"wireframe": {
|
||||||
"name": "Wireframe Visual Feedback Loop",
|
"name": "Wireframe Visual Feedback Loop",
|
||||||
"id": "wireframe",
|
"id": "wireframe",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "specify-cli"
|
name = "specify-cli"
|
||||||
version = "0.12.6"
|
version = "0.12.10.dev0"
|
||||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -97,17 +97,26 @@ read_feature_json_feature_directory() {
|
|||||||
local fj="$repo_root/.specify/feature.json"
|
local fj="$repo_root/.specify/feature.json"
|
||||||
[[ -f "$fj" ]] || { printf '%s' ''; return 0; }
|
[[ -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=''
|
local _fd=''
|
||||||
if command -v jq >/dev/null 2>&1; then
|
if command -v jq >/dev/null 2>&1; then
|
||||||
if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then
|
if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then
|
||||||
_fd=''
|
_fd=''
|
||||||
fi
|
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.
|
# 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
|
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=''
|
_fd=''
|
||||||
fi
|
fi
|
||||||
else
|
fi
|
||||||
|
if [[ -z "$_fd" ]]; then
|
||||||
# Last-resort single-line grep/sed fallback. The `|| true` guards against
|
# Last-resort single-line grep/sed fallback. The `|| true` guards against
|
||||||
# grep returning 1 (no match) aborting under `set -e` / `pipefail`.
|
# grep returning 1 (no match) aborting under `set -e` / `pipefail`.
|
||||||
_fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \
|
_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 integration_json="$repo_root/.specify/integration.json"
|
||||||
local separator="."
|
local separator="."
|
||||||
local parsed_with_jq=0
|
local parsed=0
|
||||||
|
|
||||||
if [[ -f "$integration_json" ]]; then
|
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
|
if command -v jq >/dev/null 2>&1; then
|
||||||
local jq_separator
|
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
|
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
|
case "$jq_separator" in
|
||||||
"."|"-") separator="$jq_separator" ;;
|
"."|"-") separator="$jq_separator"; parsed=1 ;;
|
||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$parsed_with_jq" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then
|
if [[ "$parsed" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then
|
||||||
if separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null
|
local py_separator
|
||||||
|
if py_separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -265,17 +282,64 @@ try:
|
|||||||
separator = entry["invoke_separator"]
|
separator = entry["invoke_separator"]
|
||||||
print(separator)
|
print(separator)
|
||||||
except Exception:
|
except Exception:
|
||||||
print(".")
|
sys.exit(1)
|
||||||
PY
|
PY
|
||||||
); then
|
); then
|
||||||
case "$separator" in
|
case "$py_separator" in
|
||||||
"."|"-") ;;
|
"."|"-") separator="$py_separator"; parsed=1 ;;
|
||||||
*) separator="." ;;
|
|
||||||
esac
|
esac
|
||||||
else
|
|
||||||
separator="."
|
|
||||||
fi
|
fi
|
||||||
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
|
fi
|
||||||
|
|
||||||
_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root"
|
_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root"
|
||||||
|
|||||||
@@ -141,8 +141,9 @@ def _install_shared_infra(
|
|||||||
|
|
||||||
Copies ``.specify/scripts/<variant>/`` and ``.specify/templates/`` from
|
Copies ``.specify/scripts/<variant>/`` and ``.specify/templates/`` from
|
||||||
the bundled core_pack or source checkout, where ``<variant>`` is
|
the bundled core_pack or source checkout, where ``<variant>`` is
|
||||||
``bash`` when *script_type* is ``"sh"`` and ``powershell`` when it is
|
``bash`` when *script_type* is ``"sh"``, ``python`` when it is ``"py"``,
|
||||||
``"ps"``. Tracks all installed files in ``speckit.manifest.json``.
|
and ``powershell`` when it is ``"ps"``. Tracks all installed files in
|
||||||
|
``speckit.manifest.json``.
|
||||||
|
|
||||||
Shared scripts and page templates are processed to resolve
|
Shared scripts and page templates are processed to resolve
|
||||||
``__SPECKIT_COMMAND_<NAME>__`` placeholders using *invoke_separator*
|
``__SPECKIT_COMMAND_<NAME>__`` placeholders using *invoke_separator*
|
||||||
|
|||||||
@@ -127,7 +127,14 @@ def resolve_github_release_asset_api_url(
|
|||||||
if hostname == "github.com":
|
if hostname == "github.com":
|
||||||
api_base = "https://api.github.com"
|
api_base = "https://api.github.com"
|
||||||
elif is_ghes:
|
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"
|
api_base = f"{parsed.scheme}://{authority}/api/v3"
|
||||||
else:
|
else:
|
||||||
return None
|
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
|
import yaml
|
||||||
|
|
||||||
from ._init_options import is_ai_skills_enabled, load_init_options
|
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
|
from ._utils import relative_extension_path_violation
|
||||||
|
|
||||||
|
|
||||||
@@ -258,7 +260,12 @@ class CommandRegistrar:
|
|||||||
# ``C:\\Users\\...`` whose ``\\U`` reads as an invalid unicode escape) would
|
# ``C:\\Users\\...`` whose ``\\U`` reads as an invalid unicode escape) would
|
||||||
# produce unparseable TOML — route those to the *literal* form ('''...'''),
|
# produce unparseable TOML — route those to the *literal* form ('''...'''),
|
||||||
# which does not process escapes, or to the escaped basic string.
|
# 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('prompt = """')
|
||||||
toml_lines.append(body)
|
toml_lines.append(body)
|
||||||
toml_lines.append('"""')
|
toml_lines.append('"""')
|
||||||
@@ -271,17 +278,11 @@ class CommandRegistrar:
|
|||||||
|
|
||||||
return "\n".join(toml_lines)
|
return "\n".join(toml_lines)
|
||||||
|
|
||||||
@staticmethod
|
# Control-char detection and basic-string escaping are shared with the
|
||||||
def _render_basic_toml_string(value: str) -> str:
|
# gemini/tabnine renderer in ``specify_cli.integrations.base`` via
|
||||||
"""Render *value* as a TOML basic string literal."""
|
# ``specify_cli._toml_string`` so the two never drift apart.
|
||||||
escaped = (
|
_has_illegal_toml_control = staticmethod(_has_illegal_toml_control)
|
||||||
value.replace("\\", "\\\\")
|
_render_basic_toml_string = staticmethod(_escape_toml_basic)
|
||||||
.replace('"', '\\"')
|
|
||||||
.replace("\n", "\\n")
|
|
||||||
.replace("\r", "\\r")
|
|
||||||
.replace("\t", "\\t")
|
|
||||||
)
|
|
||||||
return f'"{escaped}"'
|
|
||||||
|
|
||||||
def render_yaml_command(
|
def render_yaml_command(
|
||||||
self,
|
self,
|
||||||
@@ -700,12 +701,29 @@ class CommandRegistrar:
|
|||||||
)
|
)
|
||||||
output = self.render_toml_command(frontmatter, body, source_id)
|
output = self.render_toml_command(frontmatter, body, source_id)
|
||||||
elif agent_config["format"] == "yaml":
|
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(
|
output = self.render_yaml_command(
|
||||||
frontmatter, body, source_id, cmd_name
|
frontmatter, body, source_id, cmd_name
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported format: {agent_config['format']}")
|
raise ValueError(f"Unsupported format: {agent_config['format']}")
|
||||||
|
|
||||||
|
# -- Post-process for non-skills agents -----------------------
|
||||||
|
_integration = None
|
||||||
|
if agent_config["extension"] != "/SKILL.md":
|
||||||
|
from specify_cli.integrations import ( # noqa: PLC0415
|
||||||
|
get_integration,
|
||||||
|
)
|
||||||
|
|
||||||
|
_integration = get_integration(agent_name)
|
||||||
|
if _integration is not None:
|
||||||
|
output = _integration.post_process_command_content(output)
|
||||||
|
|
||||||
dest_file = commands_dir / f"{output_name}{agent_config['extension']}"
|
dest_file = commands_dir / f"{output_name}{agent_config['extension']}"
|
||||||
self._ensure_inside(dest_file, commands_dir)
|
self._ensure_inside(dest_file, commands_dir)
|
||||||
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -766,6 +784,9 @@ class CommandRegistrar:
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Unsupported format: {agent_config['format']}"
|
f"Unsupported format: {agent_config['format']}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if agent_config["extension"] != "/SKILL.md" and _integration is not None:
|
||||||
|
alias_output = _integration.post_process_command_content(alias_output)
|
||||||
else:
|
else:
|
||||||
# For other agents, reuse the primary output
|
# For other agents, reuse the primary output
|
||||||
alias_output = output
|
alias_output = output
|
||||||
|
|||||||
@@ -73,6 +73,13 @@ class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
|
|||||||
self._redirect_validator = redirect_validator
|
self._redirect_validator = redirect_validator
|
||||||
|
|
||||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
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:
|
if self._redirect_validator is not None:
|
||||||
self._redirect_validator(req.full_url, newurl)
|
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)
|
new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||||
if new_req is not None:
|
if new_req is not None:
|
||||||
old_scheme = urlparse(req.full_url).scheme
|
old_scheme = urlparse(req.full_url).scheme
|
||||||
new_parsed = urlparse(newurl)
|
|
||||||
hostname = (new_parsed.hostname or "").lower()
|
hostname = (new_parsed.hostname or "").lower()
|
||||||
is_https_downgrade = old_scheme == "https" and new_parsed.scheme != "https"
|
is_https_downgrade = old_scheme == "https" and new_parsed.scheme != "https"
|
||||||
if _hostname_in_hosts(hostname, self._hosts) and not is_https_downgrade:
|
if _hostname_in_hosts(hostname, self._hosts) and not is_https_downgrade:
|
||||||
|
|||||||
@@ -130,6 +130,28 @@ def install_bundle(
|
|||||||
done.append(component)
|
done.append(component)
|
||||||
result.installed.append(component)
|
result.installed.append(component)
|
||||||
contributed.append(component)
|
contributed.append(component)
|
||||||
|
|
||||||
|
# On update (refresh), uninstall components this bundle used to own
|
||||||
|
# that the new version no longer ships. Otherwise they are dropped
|
||||||
|
# from the record below (contributed only holds plan.components) yet
|
||||||
|
# left on disk — permanently orphaned, since no bundle record can
|
||||||
|
# ever remove them. A stale component still owned by another bundle
|
||||||
|
# is kept installed and simply de-attributed here (it stays in that
|
||||||
|
# bundle's record). Mirrors remove_bundle's refcount logic.
|
||||||
|
if refresh and existing is not None:
|
||||||
|
planned = {(c.kind, c.id) for c in plan.components}
|
||||||
|
still_needed = components_still_needed(
|
||||||
|
records, exclude_bundle_id=plan.bundle_id
|
||||||
|
)
|
||||||
|
for component in existing.contributed_components:
|
||||||
|
key = (component.kind, component.id)
|
||||||
|
if key in planned:
|
||||||
|
continue
|
||||||
|
if key in still_needed:
|
||||||
|
continue
|
||||||
|
if installer.is_installed(project_root, component):
|
||||||
|
installer.remove(project_root, component)
|
||||||
|
result.uninstalled.append(component)
|
||||||
except BundlerError:
|
except BundlerError:
|
||||||
_rollback(project_root, installer, done)
|
_rollback(project_root, installer, done)
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -33,12 +33,13 @@ DEFAULT_PRIORITY = 10
|
|||||||
def _assert_pinned_version(
|
def _assert_pinned_version(
|
||||||
kind: str, component_id: str, pinned: str | None, advertised: object
|
kind: str, component_id: str, pinned: str | None, advertised: object
|
||||||
) -> None:
|
) -> 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
|
Bundle manifests pin component versions for reproducibility; installing
|
||||||
whatever the active catalog currently serves would silently violate the
|
whatever the resolved source (catalog *or* bundled asset) provides would
|
||||||
pin. When the catalog advertises no version we cannot enforce the pin, so
|
silently violate the pin. When the source advertises no version we cannot
|
||||||
installation proceeds (the catalog, not the bundler, owns that gap).
|
enforce the pin, so installation proceeds (the source, not the bundler,
|
||||||
|
owns that gap).
|
||||||
"""
|
"""
|
||||||
if not pinned or advertised is None:
|
if not pinned or advertised is None:
|
||||||
return
|
return
|
||||||
@@ -54,11 +55,35 @@ def _assert_pinned_version(
|
|||||||
if not matches:
|
if not matches:
|
||||||
raise BundlerError(
|
raise BundlerError(
|
||||||
f"{kind} '{component_id}' is pinned to version {pinned} in the bundle "
|
f"{kind} '{component_id}' is pinned to version {pinned} in the bundle "
|
||||||
f"manifest, but the active catalog serves {actual}. Update the bundle's "
|
f"manifest, but the resolved version is {actual}. Update the bundle's "
|
||||||
"pinned version or the catalog before installing."
|
"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):
|
class _KindManager(Protocol):
|
||||||
def is_installed(self, component: ComponentRef) -> bool: ...
|
def is_installed(self, component: ComponentRef) -> bool: ...
|
||||||
|
|
||||||
@@ -134,6 +159,15 @@ class _PresetKindManager:
|
|||||||
|
|
||||||
bundled = _locate_bundled_preset(component.id)
|
bundled = _locate_bundled_preset(component.id)
|
||||||
if bundled is not None:
|
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)
|
self._manager.install_from_directory(bundled, speckit_version, priority)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -198,6 +232,15 @@ class _ExtensionKindManager:
|
|||||||
|
|
||||||
bundled = _locate_bundled_extension(component.id)
|
bundled = _locate_bundled_extension(component.id)
|
||||||
if bundled is not None:
|
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(
|
self._manager.install_from_directory(
|
||||||
bundled, speckit_version, priority=priority
|
bundled, speckit_version, priority=priority
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -426,7 +426,11 @@ def extension_add(
|
|||||||
if from_url and not dev:
|
if from_url and not dev:
|
||||||
from urllib.parse import urlparse
|
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")
|
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||||
|
|
||||||
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
||||||
|
|||||||
@@ -89,7 +89,11 @@ class AgyIntegration(SkillsIntegration):
|
|||||||
output_json: bool = True,
|
output_json: bool = True,
|
||||||
) -> list[str] | None:
|
) -> list[str] | None:
|
||||||
# agy does not support --model or JSON output; both params are ignored
|
# agy does not support --model or JSON output; both params are ignored
|
||||||
return [self._resolve_executable(), "--print", prompt]
|
args = [self._resolve_executable(), "--print", prompt]
|
||||||
|
# Honor SPECKIT_INTEGRATION_AGY_EXTRA_ARGS (operator-supplied flags),
|
||||||
|
# appended after the positional prompt like the devin integration.
|
||||||
|
self._apply_extra_args_env_var(args)
|
||||||
|
return args
|
||||||
|
|
||||||
def setup(
|
def setup(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from abc import ABC
|
from abc import ABC
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -25,6 +26,9 @@ from typing import TYPE_CHECKING, Any
|
|||||||
|
|
||||||
import yaml
|
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:
|
if TYPE_CHECKING:
|
||||||
from .manifest import IntegrationManifest
|
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
|
# IntegrationOption
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -123,6 +139,19 @@ class IntegrationBase(ABC):
|
|||||||
integration that sets this flag.
|
integration that sets this flag.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def post_process_command_content(self, content: str) -> str:
|
||||||
|
"""Transform command content after format rendering.
|
||||||
|
|
||||||
|
Called by ``register_commands()`` for non-skills format types
|
||||||
|
(Markdown, TOML, YAML) after the command has been rendered into
|
||||||
|
its target format and before writing to disk. Skills-format
|
||||||
|
agents use ``post_process_skill_content()`` instead.
|
||||||
|
|
||||||
|
Subclasses may override to inject agent-specific content.
|
||||||
|
The default implementation returns *content* unchanged.
|
||||||
|
"""
|
||||||
|
return content
|
||||||
|
|
||||||
# -- Public API -------------------------------------------------------
|
# -- Public API -------------------------------------------------------
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -576,10 +605,42 @@ class IntegrationBase(ABC):
|
|||||||
if candidate.exists():
|
if candidate.exists():
|
||||||
return relative
|
return relative
|
||||||
for name in ("python3", "python"):
|
for name in ("python3", "python"):
|
||||||
if shutil.which(name):
|
found = shutil.which(name)
|
||||||
return 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"
|
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
|
@staticmethod
|
||||||
def process_template(
|
def process_template(
|
||||||
content: str,
|
content: str,
|
||||||
@@ -940,6 +1001,12 @@ class TomlIntegration(IntegrationBase):
|
|||||||
body = "".join(lines[frontmatter_end + 1 :])
|
body = "".join(lines[frontmatter_end + 1 :])
|
||||||
return frontmatter, body
|
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
|
@staticmethod
|
||||||
def _render_toml_string(value: str) -> str:
|
def _render_toml_string(value: str) -> str:
|
||||||
"""Render *value* as a TOML string literal.
|
"""Render *value* as a TOML string literal.
|
||||||
@@ -949,6 +1016,12 @@ class TomlIntegration(IntegrationBase):
|
|||||||
literal string or escaped basic string when delimiters appear in
|
literal string or escaped basic string when delimiters appear in
|
||||||
the content.
|
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:
|
if "\n" not in value and "\r" not in value:
|
||||||
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
return f'"{escaped}"'
|
return f'"{escaped}"'
|
||||||
@@ -961,17 +1034,7 @@ class TomlIntegration(IntegrationBase):
|
|||||||
if "'''" not in value and not value.endswith("'"):
|
if "'''" not in value and not value.endswith("'"):
|
||||||
return "'''\n" + value + "'''"
|
return "'''\n" + value + "'''"
|
||||||
|
|
||||||
return (
|
return TomlIntegration._escape_toml_basic(value)
|
||||||
'"'
|
|
||||||
+ (
|
|
||||||
value.replace("\\", "\\\\")
|
|
||||||
.replace('"', '\\"')
|
|
||||||
.replace("\n", "\\n")
|
|
||||||
.replace("\r", "\\r")
|
|
||||||
.replace("\t", "\\t")
|
|
||||||
)
|
|
||||||
+ '"'
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _render_toml(description: str, body: str) -> str:
|
def _render_toml(description: str, body: str) -> str:
|
||||||
@@ -1059,7 +1122,6 @@ class TomlIntegration(IntegrationBase):
|
|||||||
# YamlIntegration — YAML-format agents (Goose)
|
# YamlIntegration — YAML-format agents (Goose)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class YamlIntegration(IntegrationBase):
|
class YamlIntegration(IntegrationBase):
|
||||||
"""Concrete base for integrations that use YAML recipe format.
|
"""Concrete base for integrations that use YAML recipe format.
|
||||||
|
|
||||||
@@ -1462,21 +1524,17 @@ class SkillsIntegration(IntegrationBase):
|
|||||||
if not description:
|
if not description:
|
||||||
description = f"Spec Kit: {command_name} workflow"
|
description = f"Spec Kit: {command_name} workflow"
|
||||||
|
|
||||||
# Build SKILL.md with manually formatted frontmatter to match
|
# Build SKILL.md with manually formatted frontmatter (stable
|
||||||
# the release packaging script output exactly (double-quoted
|
# double-quoted values). yaml_quote escapes newlines and control
|
||||||
# values, no yaml.safe_dump quoting differences).
|
# characters that a plain quoted f-string cannot carry.
|
||||||
def _quote(v: str) -> str:
|
|
||||||
escaped = v.replace("\\", "\\\\").replace('"', '\\"')
|
|
||||||
return f'"{escaped}"'
|
|
||||||
|
|
||||||
skill_content = (
|
skill_content = (
|
||||||
f"---\n"
|
f"---\n"
|
||||||
f"name: {_quote(skill_name)}\n"
|
f"name: {yaml_quote(skill_name)}\n"
|
||||||
f"description: {_quote(description)}\n"
|
f"description: {yaml_quote(description)}\n"
|
||||||
f"compatibility: {_quote('Requires spec-kit project structure with .specify/ directory')}\n"
|
f"compatibility: {yaml_quote('Requires spec-kit project structure with .specify/ directory')}\n"
|
||||||
f"metadata:\n"
|
f"metadata:\n"
|
||||||
f" author: {_quote('github-spec-kit')}\n"
|
f" author: {yaml_quote('github-spec-kit')}\n"
|
||||||
f" source: {_quote('templates/commands/' + src_file.name)}\n"
|
f" source: {yaml_quote('templates/commands/' + src_file.name)}\n"
|
||||||
f"---\n"
|
f"---\n"
|
||||||
f"{processed_body}"
|
f"{processed_body}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from typing import Any
|
|||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from ..base import IntegrationOption, SkillsIntegration
|
from ..base import IntegrationOption, SkillsIntegration, yaml_quote
|
||||||
from ..manifest import IntegrationManifest
|
from ..manifest import IntegrationManifest
|
||||||
|
|
||||||
|
|
||||||
@@ -153,20 +153,18 @@ class HermesIntegration(SkillsIntegration):
|
|||||||
if not description:
|
if not description:
|
||||||
description = f"Spec Kit: {command_name} workflow"
|
description = f"Spec Kit: {command_name} workflow"
|
||||||
|
|
||||||
# Build SKILL.md with manually formatted frontmatter
|
# Build SKILL.md with manually formatted frontmatter. yaml_quote
|
||||||
def _quote(v: str) -> str:
|
# escapes newlines and control characters that a plain quoted
|
||||||
escaped = v.replace("\\", "\\\\").replace('"', '\\"')
|
# f-string cannot carry.
|
||||||
return f'"{escaped}"'
|
|
||||||
|
|
||||||
skill_content = (
|
skill_content = (
|
||||||
f"---\n"
|
f"---\n"
|
||||||
f"name: {_quote(skill_name)}\n"
|
f"name: {yaml_quote(skill_name)}\n"
|
||||||
f"description: {_quote(description)}\n"
|
f"description: {yaml_quote(description)}\n"
|
||||||
f"compatibility: "
|
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"metadata:\n"
|
||||||
f" author: {_quote('github-spec-kit')}\n"
|
f" author: {yaml_quote('github-spec-kit')}\n"
|
||||||
f" source: {_quote('templates/commands/' + src_file.name)}\n"
|
f" source: {yaml_quote('templates/commands/' + src_file.name)}\n"
|
||||||
f"---\n"
|
f"---\n"
|
||||||
f"{processed_body}"
|
f"{processed_body}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -309,7 +309,14 @@ class IntegrationManifest:
|
|||||||
if abs_path.is_symlink() or not abs_path.is_file():
|
if abs_path.is_symlink() or not abs_path.is_file():
|
||||||
modified.append(rel)
|
modified.append(rel)
|
||||||
continue
|
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)
|
modified.append(rel)
|
||||||
return modified
|
return modified
|
||||||
|
|
||||||
@@ -358,9 +365,17 @@ class IntegrationManifest:
|
|||||||
skipped.append(path)
|
skipped.append(path)
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
if not force and _sha256(path) != expected_hash:
|
if not force:
|
||||||
skipped.append(path)
|
try:
|
||||||
continue
|
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:
|
try:
|
||||||
path.unlink()
|
path.unlink()
|
||||||
except OSError:
|
except OSError:
|
||||||
|
|||||||
@@ -104,7 +104,13 @@ def preset_add(
|
|||||||
from ipaddress import ip_address
|
from ipaddress import ip_address
|
||||||
from urllib.parse import urlparse as _urlparse
|
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):
|
def _is_allowed_download_url(parsed_url):
|
||||||
host = parsed_url.hostname
|
host = parsed_url.hostname
|
||||||
@@ -135,7 +141,9 @@ def preset_add(
|
|||||||
)
|
)
|
||||||
raise typer.Exit(1)
|
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 urllib.error
|
||||||
import tempfile
|
import tempfile
|
||||||
import shutil
|
import shutil
|
||||||
|
|||||||
@@ -328,7 +328,10 @@ def refresh_shared_templates(
|
|||||||
_ensure_safe_shared_destination(project_path, dst)
|
_ensure_safe_shared_destination(project_path, dst)
|
||||||
rel = dst.relative_to(project_path).as_posix()
|
rel = dst.relative_to(project_path).as_posix()
|
||||||
if dst.exists() and not force:
|
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)
|
skipped_files.append(rel)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -344,7 +347,7 @@ def refresh_shared_templates(
|
|||||||
|
|
||||||
if skipped_files:
|
if skipped_files:
|
||||||
console.print(
|
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:
|
for rel in skipped_files:
|
||||||
console.print(f" {rel}")
|
console.print(f" {rel}")
|
||||||
@@ -400,7 +403,7 @@ def install_shared_infra(
|
|||||||
# manifest entries the core no longer ships (stale-script cleanup, #3076).
|
# manifest entries the core no longer ships (stale-script cleanup, #3076).
|
||||||
seen_rels: set[str] = set()
|
seen_rels: set[str] = set()
|
||||||
scripts_scanned = False
|
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]:
|
def _decide_overwrite(rel: str, dst: Path) -> tuple[bool, str | None]:
|
||||||
"""Return (write, bucket) where bucket is 'skip', 'preserved', or 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("*"):
|
for src_path in variant_src.rglob("*"):
|
||||||
if not src_path.is_file():
|
if not src_path.is_file():
|
||||||
continue
|
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
|
# Mark scanned only once a real source file is seen. An
|
||||||
# empty (or symlink-skipped) variant keeps this False, so
|
# empty (or symlink-skipped) variant keeps this False, so
|
||||||
# stale-cleanup is skipped — otherwise it would treat every
|
# stale-cleanup is skipped — otherwise it would treat every
|
||||||
|
|||||||
@@ -50,7 +50,17 @@ workflow_step_catalog_app = typer.Typer(
|
|||||||
workflow_step_app.add_typer(workflow_step_catalog_app, name="catalog")
|
workflow_step_app.add_typer(workflow_step_catalog_app, name="catalog")
|
||||||
|
|
||||||
|
|
||||||
def _parse_input_values(input_values: list[str] | None) -> dict[str, Any]:
|
def _error_console(json_output: bool):
|
||||||
|
"""Console for error text: stderr under ``--json`` so the JSON stdout
|
||||||
|
stream stays parseable, the normal console otherwise. Mirrors the
|
||||||
|
stderr-only error routing already used by ``specify bundle``.
|
||||||
|
"""
|
||||||
|
return err_console if json_output else console
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_input_values(
|
||||||
|
input_values: list[str] | None, *, json_output: bool = False
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""Parse repeated ``key=value`` CLI inputs into a dict.
|
"""Parse repeated ``key=value`` CLI inputs into a dict.
|
||||||
|
|
||||||
Shared by ``workflow run`` and ``workflow resume``. Exits with an error
|
Shared by ``workflow run`` and ``workflow resume``. Exits with an error
|
||||||
@@ -59,7 +69,9 @@ def _parse_input_values(input_values: list[str] | None) -> dict[str, Any]:
|
|||||||
inputs: dict[str, Any] = {}
|
inputs: dict[str, Any] = {}
|
||||||
for kv in input_values or []:
|
for kv in input_values or []:
|
||||||
if "=" not in kv:
|
if "=" not in kv:
|
||||||
console.print(f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)")
|
_error_console(json_output).print(
|
||||||
|
f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)"
|
||||||
|
)
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
key, _, value = kv.partition("=")
|
key, _, value = kv.partition("=")
|
||||||
inputs[key.strip()] = value.strip()
|
inputs[key.strip()] = value.strip()
|
||||||
@@ -335,25 +347,26 @@ def workflow_run(
|
|||||||
if not json_output:
|
if not json_output:
|
||||||
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
|
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
|
||||||
|
|
||||||
|
err = _error_console(json_output)
|
||||||
try:
|
try:
|
||||||
definition = engine.load_workflow(source_path if is_file_source else source)
|
definition = engine.load_workflow(source_path if is_file_source else source)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
console.print(f"[red]Error:[/red] Workflow not found: {source}")
|
err.print(f"[red]Error:[/red] Workflow not found: {source}")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
console.print(f"[red]Error:[/red] Invalid workflow: {exc}")
|
err.print(f"[red]Error:[/red] Invalid workflow: {exc}")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
# Validate
|
# Validate
|
||||||
errors = engine.validate(definition)
|
errors = engine.validate(definition)
|
||||||
if errors:
|
if errors:
|
||||||
console.print("[red]Workflow validation failed:[/red]")
|
err.print("[red]Workflow validation failed:[/red]")
|
||||||
for err in errors:
|
for verr in errors:
|
||||||
console.print(f" • {err}")
|
err.print(f" • {verr}")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
# Parse inputs
|
# Parse inputs
|
||||||
inputs = _parse_input_values(input_values)
|
inputs = _parse_input_values(input_values, json_output=json_output)
|
||||||
|
|
||||||
if not json_output:
|
if not json_output:
|
||||||
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
|
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
|
||||||
@@ -363,10 +376,10 @@ def workflow_run(
|
|||||||
with _stdout_to_stderr_when(json_output):
|
with _stdout_to_stderr_when(json_output):
|
||||||
state = engine.execute(definition, inputs)
|
state = engine.execute(definition, inputs)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
console.print(f"[red]Error:[/red] {exc}")
|
err.print(f"[red]Error:[/red] {exc}")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
console.print(f"[red]Workflow failed:[/red] {exc}")
|
err.print(f"[red]Workflow failed:[/red] {exc}")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
if json_output:
|
if json_output:
|
||||||
@@ -411,19 +424,20 @@ def workflow_resume(
|
|||||||
if not json_output:
|
if not json_output:
|
||||||
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
|
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
|
||||||
|
|
||||||
inputs = _parse_input_values(input_values)
|
inputs = _parse_input_values(input_values, json_output=json_output)
|
||||||
|
err = _error_console(json_output)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with _stdout_to_stderr_when(json_output):
|
with _stdout_to_stderr_when(json_output):
|
||||||
state = engine.resume(run_id, inputs or None)
|
state = engine.resume(run_id, inputs or None)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
console.print(f"[red]Error:[/red] Run not found: {run_id}")
|
err.print(f"[red]Error:[/red] Run not found: {run_id}")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
console.print(f"[red]Error:[/red] {exc}")
|
err.print(f"[red]Error:[/red] {exc}")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
console.print(f"[red]Resume failed:[/red] {exc}")
|
err.print(f"[red]Resume failed:[/red] {exc}")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
if json_output:
|
if json_output:
|
||||||
@@ -617,7 +631,11 @@ def workflow_add(
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from specify_cli.authentication.http import open_url as _open_url
|
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_host = parsed_src.hostname or ""
|
||||||
src_loopback = src_host == "localhost"
|
src_loopback = src_host == "localhost"
|
||||||
if not src_loopback:
|
if not src_loopback:
|
||||||
|
|||||||
@@ -242,6 +242,26 @@ def _interpolate_expressions(template: str, namespace: dict[str, Any]) -> str:
|
|||||||
return "".join(out)
|
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]:
|
def _split_top_level_commas(text: str) -> list[str]:
|
||||||
"""Split *text* on commas that are not inside quotes or nested brackets.
|
"""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
|
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:
|
def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
|
||||||
"""Evaluate a simple expression against the namespace.
|
"""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
|
# 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
|
# '|' 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.
|
# 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, "|")
|
pipe_idx = _find_top_level(expr, "|")
|
||||||
if pipe_idx != -1:
|
if pipe_idx != -1:
|
||||||
value = _evaluate_simple_expression(expr[:pipe_idx].strip(), namespace)
|
segments = _split_top_level(expr, "|")
|
||||||
filter_expr = expr[pipe_idx + 1:].strip()
|
value = _evaluate_simple_expression(segments[0].strip(), namespace)
|
||||||
|
for segment in segments[1:]:
|
||||||
# `from_json` is strict: it takes no arguments and tolerates no
|
value = _apply_filter(value, segment.strip(), namespace)
|
||||||
# trailing tokens. Match on the leading filter name and require the
|
return value
|
||||||
# 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}')"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Boolean operators — parse 'or' first (lower precedence) so that
|
# 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
|
# 'a or b and c' is evaluated as 'a or (b and c)'. Splits are quote/bracket
|
||||||
|
|||||||
@@ -58,4 +58,13 @@ class FanInStep(StepBase):
|
|||||||
f"Fan-in step {config.get('id', '?')!r}: "
|
f"Fan-in step {config.get('id', '?')!r}: "
|
||||||
f"'wait_for' must be a non-empty list of step IDs."
|
f"'wait_for' must be a non-empty list of step IDs."
|
||||||
)
|
)
|
||||||
|
output = config.get("output")
|
||||||
|
if output is not None and not isinstance(output, dict):
|
||||||
|
# execute() silently coerces a non-mapping output to {}, so the
|
||||||
|
# author's declared aggregation keys would vanish with no error.
|
||||||
|
# Reject at validation, mirroring the command-step (#3262) fix.
|
||||||
|
errors.append(
|
||||||
|
f"Fan-in step {config.get('id', '?')!r}: 'output' must be a "
|
||||||
|
f"mapping of key -> expression, got {type(output).__name__}."
|
||||||
|
)
|
||||||
return errors
|
return errors
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ class ShellStep(StepBase):
|
|||||||
run_cmd = str(run_cmd)
|
run_cmd = str(run_cmd)
|
||||||
|
|
||||||
cwd = context.project_root or "."
|
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
|
# NOTE: shell=True is required to support pipes, redirects, and
|
||||||
# multi-command expressions in workflow YAML. Workflow authors
|
# multi-command expressions in workflow YAML. Workflow authors
|
||||||
@@ -37,7 +45,7 @@ class ShellStep(StepBase):
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
cwd=cwd,
|
cwd=cwd,
|
||||||
timeout=300,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
output = {
|
output = {
|
||||||
"exit_code": proc.returncode,
|
"exit_code": proc.returncode,
|
||||||
@@ -74,7 +82,7 @@ class ShellStep(StepBase):
|
|||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return StepResult(
|
return StepResult(
|
||||||
status=StepStatus.FAILED,
|
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"},
|
output={"exit_code": -1, "stdout": "", "stderr": "timeout"},
|
||||||
)
|
)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
@@ -90,10 +98,32 @@ class ShellStep(StepBase):
|
|||||||
errors.append(
|
errors.append(
|
||||||
f"Shell step {config.get('id', '?')!r} is missing 'run' field."
|
f"Shell step {config.get('id', '?')!r} is missing 'run' field."
|
||||||
)
|
)
|
||||||
|
elif not isinstance(config["run"], str):
|
||||||
|
# execute() str()-coerces run and invokes it under shell=True, so a
|
||||||
|
# null or list 'run' would run the Python repr ('None', "['echo']")
|
||||||
|
# as a command. Reject non-strings at validation, mirroring the
|
||||||
|
# command-step input/options and gate options type checks. An
|
||||||
|
# expression like "{{ ... }}" is still a str, so it stays valid.
|
||||||
|
errors.append(
|
||||||
|
f"Shell step {config.get('id', '?')!r}: 'run' must be a string, "
|
||||||
|
f"got {type(config['run']).__name__}."
|
||||||
|
)
|
||||||
output_format = config.get("output_format")
|
output_format = config.get("output_format")
|
||||||
if output_format is not None and output_format != "json":
|
if output_format is not None and output_format != "json":
|
||||||
errors.append(
|
errors.append(
|
||||||
f"Shell step {config.get('id', '?')!r}: 'output_format' must "
|
f"Shell step {config.get('id', '?')!r}: 'output_format' must "
|
||||||
f"be 'json' when present, got {output_format!r}."
|
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
|
return errors
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ description: Perform a non-destructive cross-artifact consistency and quality an
|
|||||||
scripts:
|
scripts:
|
||||||
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
|
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
|
||||||
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
|
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
|
||||||
|
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
|
||||||
---
|
---
|
||||||
|
|
||||||
## User Input
|
## User Input
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ description: Generate a custom checklist for the current feature based on user r
|
|||||||
scripts:
|
scripts:
|
||||||
sh: scripts/bash/check-prerequisites.sh --json
|
sh: scripts/bash/check-prerequisites.sh --json
|
||||||
ps: scripts/powershell/check-prerequisites.ps1 -Json
|
ps: scripts/powershell/check-prerequisites.ps1 -Json
|
||||||
|
py: scripts/python/check_prerequisites.py --json
|
||||||
---
|
---
|
||||||
|
|
||||||
## Checklist Purpose: "Unit Tests for English"
|
## Checklist Purpose: "Unit Tests for English"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ handoffs:
|
|||||||
scripts:
|
scripts:
|
||||||
sh: scripts/bash/check-prerequisites.sh --json --paths-only
|
sh: scripts/bash/check-prerequisites.sh --json --paths-only
|
||||||
ps: scripts/powershell/check-prerequisites.ps1 -Json -PathsOnly
|
ps: scripts/powershell/check-prerequisites.ps1 -Json -PathsOnly
|
||||||
|
py: scripts/python/check_prerequisites.py --json --paths-only
|
||||||
---
|
---
|
||||||
|
|
||||||
## User Input
|
## User Input
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ description: Assess the current codebase against the feature's spec, plan, and t
|
|||||||
scripts:
|
scripts:
|
||||||
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
|
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
|
||||||
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
|
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
|
||||||
|
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
|
||||||
---
|
---
|
||||||
|
|
||||||
## User Input
|
## User Input
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ description: Execute the implementation plan by processing and executing all tas
|
|||||||
scripts:
|
scripts:
|
||||||
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
|
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
|
||||||
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
|
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
|
||||||
|
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
|
||||||
---
|
---
|
||||||
|
|
||||||
## User Input
|
## User Input
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ tools: ['github/github-mcp-server/list_issues', 'github/github-mcp-server/issue_
|
|||||||
scripts:
|
scripts:
|
||||||
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
|
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
|
||||||
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
|
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
|
||||||
|
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
|
||||||
---
|
---
|
||||||
|
|
||||||
## User Input
|
## User Input
|
||||||
|
|||||||
@@ -688,6 +688,62 @@ class TestExtensionSelfSeed:
|
|||||||
_MDC_CONTEXT_FILE = ".cursor/rules/specify-rules.mdc"
|
_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:
|
class TestMdcFrontmatter:
|
||||||
"""Cursor-style ``.mdc`` targets must carry ``alwaysApply: true`` frontmatter
|
"""Cursor-style ``.mdc`` targets must carry ``alwaysApply: true`` frontmatter
|
||||||
so the rule file is auto-loaded; non-``.mdc`` targets must not gain any."""
|
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()
|
||||||
@@ -220,3 +220,68 @@ def test_pre_existing_component_is_not_attributed_or_removed(tmp_path: Path):
|
|||||||
|
|
||||||
remove_bundle(tmp_path, "demo-bundle", installer)
|
remove_bundle(tmp_path, "demo-bundle", installer)
|
||||||
assert ("extensions", "ext-a") in installer.installed
|
assert ("extensions", "ext-a") in installer.installed
|
||||||
|
|
||||||
|
|
||||||
|
def _bundle(manifest_id, ext_ids, *, version="1.0.0"):
|
||||||
|
data = valid_manifest_dict()
|
||||||
|
data["bundle"]["id"] = manifest_id
|
||||||
|
data["bundle"]["version"] = version
|
||||||
|
data["provides"] = {
|
||||||
|
"extensions": [{"id": e, "version": version} for e in ext_ids]
|
||||||
|
}
|
||||||
|
return BundleManifest.from_dict(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_uninstalls_components_dropped_by_new_version(tmp_path: Path):
|
||||||
|
"""`bundle update` must uninstall components the new version no longer
|
||||||
|
ships, instead of orphaning them (installed on disk, tracked by nothing)."""
|
||||||
|
make_project(tmp_path)
|
||||||
|
installer = FakeInstaller()
|
||||||
|
|
||||||
|
man_v1 = _bundle("demo", ["ext-a", "ext-b"])
|
||||||
|
install_bundle(tmp_path, _plan(man_v1), installer, manifest=man_v1)
|
||||||
|
assert ("extensions", "ext-b") in installer.installed
|
||||||
|
|
||||||
|
man_v2 = _bundle("demo", ["ext-a"], version="2.0.0")
|
||||||
|
result = install_bundle(
|
||||||
|
tmp_path, _plan(man_v2), installer, manifest=man_v2, refresh=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# ext-b was dropped by v2 -> uninstalled and reported.
|
||||||
|
assert ("extensions", "ext-b") in installer.remove_calls
|
||||||
|
assert ("extensions", "ext-b") in {(c.kind, c.id) for c in result.uninstalled}
|
||||||
|
assert ("extensions", "ext-b") not in installer.installed
|
||||||
|
assert ("extensions", "ext-a") in installer.installed
|
||||||
|
|
||||||
|
# The saved record lists only ext-a.
|
||||||
|
rec = next(r for r in load_records(tmp_path) if r.bundle_id == "demo")
|
||||||
|
keys = {(c.kind, c.id) for c in rec.contributed_components}
|
||||||
|
assert ("extensions", "ext-a") in keys
|
||||||
|
assert ("extensions", "ext-b") not in keys
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_keeps_component_still_needed_by_sibling_bundle(tmp_path: Path):
|
||||||
|
"""A dropped component still owned by another bundle stays installed."""
|
||||||
|
make_project(tmp_path)
|
||||||
|
installer = FakeInstaller()
|
||||||
|
|
||||||
|
man_sib = _bundle("sibling", ["ext-b"])
|
||||||
|
install_bundle(tmp_path, _plan(man_sib), installer, manifest=man_sib)
|
||||||
|
|
||||||
|
man_v1 = _bundle("demo", ["ext-a", "ext-b"])
|
||||||
|
install_bundle(tmp_path, _plan(man_v1), installer, manifest=man_v1)
|
||||||
|
|
||||||
|
man_v2 = _bundle("demo", ["ext-a"], version="2.0.0")
|
||||||
|
install_bundle(
|
||||||
|
tmp_path, _plan(man_v2), installer, manifest=man_v2, refresh=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# ext-b is still needed by 'sibling' -> not removed, stays installed.
|
||||||
|
assert ("extensions", "ext-b") not in installer.remove_calls
|
||||||
|
assert ("extensions", "ext-b") in installer.installed
|
||||||
|
|
||||||
|
# But demo's record no longer attributes it.
|
||||||
|
rec = next(r for r in load_records(tmp_path) if r.bundle_id == "demo")
|
||||||
|
assert ("extensions", "ext-b") not in {
|
||||||
|
(c.kind, c.id) for c in rec.contributed_components
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import pytest
|
|||||||
from specify_cli.integrations.base import MarkdownIntegration
|
from specify_cli.integrations.base import MarkdownIntegration
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
def _redirect_home(monkeypatch: pytest.MonkeyPatch, home) -> None:
|
||||||
def _isolate_integration_home(monkeypatch: pytest.MonkeyPatch, tmp_path):
|
"""Point HOME/USERPROFILE/XDG env vars at an isolated *home* directory."""
|
||||||
"""Keep integration tests from reading or writing the real user home."""
|
|
||||||
home = tmp_path / "home"
|
|
||||||
for path in (home, home / ".cache", home / ".config", home / ".local" / "share"):
|
for path in (home, home / ".cache", home / ".config", home / ".local" / "share"):
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
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"))
|
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):
|
class StubIntegration(MarkdownIntegration):
|
||||||
"""Minimal concrete integration for testing."""
|
"""Minimal concrete integration for testing."""
|
||||||
|
|
||||||
|
|||||||
@@ -306,9 +306,12 @@ class TestResolveCommandRefs:
|
|||||||
class TestResolvePythonInterpreter:
|
class TestResolvePythonInterpreter:
|
||||||
def test_returns_python_on_path(self, monkeypatch):
|
def test_returns_python_on_path(self, monkeypatch):
|
||||||
# Positive: when python3 is on PATH it is preferred over python.
|
# 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):
|
def fake_which(name):
|
||||||
return f"/usr/bin/{name}" if name in ("python3", "python") else None
|
return f"/usr/bin/{name}" if name in ("python3", "python") else None
|
||||||
|
|
||||||
|
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"specify_cli.integrations.base.shutil.which", fake_which
|
"specify_cli.integrations.base.shutil.which", fake_which
|
||||||
)
|
)
|
||||||
@@ -318,6 +321,7 @@ class TestResolvePythonInterpreter:
|
|||||||
def fake_which(name):
|
def fake_which(name):
|
||||||
return "/usr/bin/python" if name == "python" else None
|
return "/usr/bin/python" if name == "python" else None
|
||||||
|
|
||||||
|
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"specify_cli.integrations.base.shutil.which", fake_which
|
"specify_cli.integrations.base.shutil.which", fake_which
|
||||||
)
|
)
|
||||||
@@ -369,12 +373,79 @@ class TestResolvePythonInterpreter:
|
|||||||
|
|
||||||
def test_ignores_missing_venv(self, monkeypatch, tmp_path):
|
def test_ignores_missing_venv(self, monkeypatch, tmp_path):
|
||||||
# Negative: no venv directory -> PATH resolution is used instead.
|
# Negative: no venv directory -> PATH resolution is used instead.
|
||||||
|
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"specify_cli.integrations.base.shutil.which",
|
"specify_cli.integrations.base.shutil.which",
|
||||||
lambda name: "/usr/bin/python3" if name == "python3" else None,
|
lambda name: "/usr/bin/python3" if name == "python3" else None,
|
||||||
)
|
)
|
||||||
assert IntegrationBase.resolve_python_interpreter(tmp_path) == "python3"
|
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:
|
class TestProcessTemplatePyScriptType:
|
||||||
CONTENT = (
|
CONTENT = (
|
||||||
@@ -390,6 +461,7 @@ class TestProcessTemplatePyScriptType:
|
|||||||
def test_py_prefixes_interpreter(self, monkeypatch):
|
def test_py_prefixes_interpreter(self, monkeypatch):
|
||||||
# Positive: py script type prefixes a resolved interpreter and the
|
# Positive: py script type prefixes a resolved interpreter and the
|
||||||
# script path is rewritten to the .specify location.
|
# script path is rewritten to the .specify location.
|
||||||
|
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"specify_cli.integrations.base.shutil.which",
|
"specify_cli.integrations.base.shutil.which",
|
||||||
lambda name: "/usr/bin/python3" if name == "python3" else None,
|
lambda name: "/usr/bin/python3" if name == "python3" else None,
|
||||||
|
|||||||
@@ -2073,3 +2073,44 @@ class TestIntegrationCatalogDiscoveryCLI:
|
|||||||
assert listing.exit_code == 0, listing.output
|
assert listing.exit_code == 0, listing.output
|
||||||
assert "default" in listing.output
|
assert "default" in listing.output
|
||||||
assert "community" 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_CONFIG_HOME"]) == home / ".config"
|
||||||
assert Path(os.environ["XDG_DATA_HOME"]) == home / ".local" / "share"
|
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.is_dir()
|
||||||
assert (home / ".cache").is_dir()
|
assert (home / ".cache").is_dir()
|
||||||
assert (home / ".config").is_dir()
|
assert (home / ".config").is_dir()
|
||||||
|
|||||||
@@ -81,6 +81,26 @@ class TestAgyBuildExecArgs:
|
|||||||
result = i.build_exec_args("my prompt", output_json=False)
|
result = i.build_exec_args("my prompt", output_json=False)
|
||||||
assert result == ["agy", "--print", "my prompt"]
|
assert result == ["agy", "--print", "my prompt"]
|
||||||
|
|
||||||
|
def test_build_exec_args_honors_extra_args(self, monkeypatch):
|
||||||
|
"""SPECKIT_INTEGRATION_AGY_EXTRA_ARGS must be appended after the prompt.
|
||||||
|
|
||||||
|
agy previously skipped _apply_extra_args_env_var entirely, so the
|
||||||
|
documented per-integration extra-args hook was silently ignored
|
||||||
|
(same class as the merged cursor-agent fix #3265).
|
||||||
|
"""
|
||||||
|
from specify_cli.integrations import get_integration
|
||||||
|
monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_EXTRA_ARGS", "--verbose")
|
||||||
|
i = get_integration("agy")
|
||||||
|
assert i.build_exec_args("my prompt") == [
|
||||||
|
"agy", "--print", "my prompt", "--verbose",
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_build_exec_args_honors_executable_override(self, monkeypatch):
|
||||||
|
from specify_cli.integrations import get_integration
|
||||||
|
monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_EXECUTABLE", "/custom/agy")
|
||||||
|
i = get_integration("agy")
|
||||||
|
assert i.build_exec_args("my prompt")[0] == "/custom/agy"
|
||||||
|
|
||||||
|
|
||||||
class TestAgyHookCommandNote:
|
class TestAgyHookCommandNote:
|
||||||
"""Verify dot-to-hyphen normalization note is injected into hook sections."""
|
"""Verify dot-to-hyphen normalization note is injected into hook sections."""
|
||||||
|
|||||||
@@ -291,6 +291,18 @@ class TomlIntegrationTests:
|
|||||||
"closing delimiter should be inline when body does not end with a quote"
|
"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):
|
def test_toml_is_valid(self, tmp_path):
|
||||||
"""Every generated TOML file must parse without errors."""
|
"""Every generated TOML file must parse without errors."""
|
||||||
i = get_integration(self.KEY)
|
i = get_integration(self.KEY)
|
||||||
|
|||||||
@@ -36,3 +36,50 @@ class TestGooseIntegration(YamlIntegrationTests):
|
|||||||
param.get("key") == "args"
|
param.get("key") == "args"
|
||||||
for param in data.get("parameters", [])
|
for param in data.get("parameters", [])
|
||||||
), f"{recipe_file} uses {{{{args}}}} but does not declare args"
|
), 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
|
||||||
|
|||||||
@@ -481,3 +481,40 @@ class TestRecordExistingNewGuards:
|
|||||||
m = IntegrationManifest("test", tmp_path)
|
m = IntegrationManifest("test", tmp_path)
|
||||||
with pytest.raises(ValueError, match=r"canonical|'\.\.' segments"):
|
with pytest.raises(ValueError, match=r"canonical|'\.\.' segments"):
|
||||||
m.record_existing("dir/../file.txt")
|
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
|
||||||
@@ -845,6 +845,22 @@ class TestRedirectStripping:
|
|||||||
auth3 = req3.get_header("Authorization") or req3.unredirected_hdrs.get("Authorization")
|
auth3 = req3.get_header("Authorization") or req3.unredirected_hdrs.get("Authorization")
|
||||||
assert auth3 == "Bearer tok"
|
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
|
# _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()
|
||||||
@@ -1795,6 +1795,25 @@ $ARGUMENTS
|
|||||||
|
|
||||||
assert parsed["description"] == "first line\nsecond line\n"
|
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):
|
def test_render_toml_command_preserves_backslashes_in_body(self):
|
||||||
"""A backslash in the body (e.g. a Windows path) must not break TOML.
|
"""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}"
|
f"confirm must precede spinner, got: {call_order}"
|
||||||
assert result.exit_code == 0 # user declined → clean exit
|
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):
|
def test_add_status_escapes_extension_markup(self, tmp_path):
|
||||||
"""User-controlled extension names must not be parsed as Rich markup."""
|
"""User-controlled extension names must not be parsed as Rich markup."""
|
||||||
from rich.markup import escape as escape_markup
|
from rich.markup import escape as escape_markup
|
||||||
|
|||||||
@@ -233,6 +233,23 @@ class TestResolveGitHubReleaseAssetApiUrl:
|
|||||||
assert result is None
|
assert result is None
|
||||||
assert called == []
|
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):
|
def test_passthrough_for_unlisted_ghes_api_asset_url(self):
|
||||||
"""A direct GHES /api/v3 asset URL passes through even when the host is
|
"""A direct GHES /api/v3 asset URL passes through even when the host is
|
||||||
not allowlisted: passthrough issues no API request, and the download
|
not allowlisted: passthrough issues no API request, and the download
|
||||||
|
|||||||
245
tests/test_post_process.py
Normal file
245
tests/test_post_process.py
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
"""Tests for post_process_command_content() hook on IntegrationBase.
|
||||||
|
|
||||||
|
Verifies that the generalized post-processing hook:
|
||||||
|
- Runs for non-skills format types (Markdown, TOML, YAML)
|
||||||
|
- Does NOT run for skills-format agents
|
||||||
|
- Default no-op returns content unchanged
|
||||||
|
- Exceptions propagate to caller
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from specify_cli.agents import CommandRegistrar
|
||||||
|
from specify_cli.integrations.base import IntegrationBase
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def registrar():
|
||||||
|
return CommandRegistrar()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ext_dir(tmp_path):
|
||||||
|
"""Create a mock extension with a simple command template."""
|
||||||
|
ext = tmp_path / "extension"
|
||||||
|
ext.mkdir()
|
||||||
|
cmd_dir = ext / "commands"
|
||||||
|
cmd_dir.mkdir()
|
||||||
|
return ext, cmd_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _write_cmd(cmd_dir, name="review.md", body="Review the code.\n"):
|
||||||
|
cmd_file = cmd_dir / name
|
||||||
|
cmd_file.write_text(
|
||||||
|
f"---\ndescription: Test command\n---\n\n{body}",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return cmd_file
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaultNoOp:
|
||||||
|
def test_returns_content_unchanged(self):
|
||||||
|
base = IntegrationBase()
|
||||||
|
content = "Some command content\nwith multiple lines."
|
||||||
|
assert base.post_process_command_content(content) == content
|
||||||
|
|
||||||
|
def test_empty_string(self):
|
||||||
|
base = IntegrationBase()
|
||||||
|
assert base.post_process_command_content("") == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestMarkdownAgentPostProcess:
|
||||||
|
def test_opencode_post_process_applied(
|
||||||
|
self, tmp_path, registrar, ext_dir, monkeypatch
|
||||||
|
):
|
||||||
|
ext, cmd_dir = ext_dir
|
||||||
|
_write_cmd(cmd_dir)
|
||||||
|
|
||||||
|
from specify_cli.integrations import get_integration
|
||||||
|
|
||||||
|
opencode = get_integration("opencode")
|
||||||
|
marker = "<!-- POST_PROCESSED -->"
|
||||||
|
|
||||||
|
def _inject_marker(self, content):
|
||||||
|
return content + marker
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
opencode.__class__, "post_process_command_content", _inject_marker
|
||||||
|
)
|
||||||
|
|
||||||
|
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||||
|
registrar.register_commands(
|
||||||
|
"opencode", commands, "test-ext", ext, tmp_path
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd_output = tmp_path / ".opencode" / "commands" / "speckit.test.review.md"
|
||||||
|
assert cmd_output.exists()
|
||||||
|
content = cmd_output.read_text(encoding="utf-8")
|
||||||
|
assert marker in content
|
||||||
|
|
||||||
|
|
||||||
|
class TestTomlAgentPostProcess:
|
||||||
|
def test_gemini_post_process_applied(
|
||||||
|
self, tmp_path, registrar, ext_dir, monkeypatch
|
||||||
|
):
|
||||||
|
ext, cmd_dir = ext_dir
|
||||||
|
_write_cmd(cmd_dir)
|
||||||
|
|
||||||
|
from specify_cli.integrations import get_integration
|
||||||
|
|
||||||
|
gemini = get_integration("gemini")
|
||||||
|
marker = "# POST_PROCESSED"
|
||||||
|
|
||||||
|
def _inject_marker(self, content):
|
||||||
|
return content + f"\n{marker}\n"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
gemini.__class__, "post_process_command_content", _inject_marker
|
||||||
|
)
|
||||||
|
|
||||||
|
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||||
|
registrar.register_commands(
|
||||||
|
"gemini", commands, "test-ext", ext, tmp_path
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd_output = tmp_path / ".gemini" / "commands" / "speckit.test.review.toml"
|
||||||
|
assert cmd_output.exists()
|
||||||
|
content = cmd_output.read_text(encoding="utf-8")
|
||||||
|
assert marker in content
|
||||||
|
|
||||||
|
|
||||||
|
class TestYamlAgentPostProcess:
|
||||||
|
def test_goose_post_process_applied(
|
||||||
|
self, tmp_path, registrar, ext_dir, monkeypatch
|
||||||
|
):
|
||||||
|
ext, cmd_dir = ext_dir
|
||||||
|
_write_cmd(cmd_dir)
|
||||||
|
|
||||||
|
from specify_cli.integrations import get_integration
|
||||||
|
|
||||||
|
goose = get_integration("goose")
|
||||||
|
marker = "# POST_PROCESSED"
|
||||||
|
|
||||||
|
def _inject_marker(self, content):
|
||||||
|
return content + f"\n{marker}\n"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
goose.__class__, "post_process_command_content", _inject_marker
|
||||||
|
)
|
||||||
|
|
||||||
|
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||||
|
registrar.register_commands(
|
||||||
|
"goose", commands, "test-ext", ext, tmp_path
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd_output = tmp_path / ".goose" / "recipes" / "speckit.test.review.yaml"
|
||||||
|
assert cmd_output.exists()
|
||||||
|
content = cmd_output.read_text(encoding="utf-8")
|
||||||
|
assert marker in content
|
||||||
|
|
||||||
|
|
||||||
|
class TestSkillsAgentExcluded:
|
||||||
|
def test_claude_post_process_not_called(
|
||||||
|
self, tmp_path, registrar, ext_dir, monkeypatch
|
||||||
|
):
|
||||||
|
ext, cmd_dir = ext_dir
|
||||||
|
_write_cmd(cmd_dir)
|
||||||
|
|
||||||
|
from specify_cli.integrations import get_integration
|
||||||
|
|
||||||
|
claude = get_integration("claude")
|
||||||
|
marker = "<!-- SHOULD_NOT_APPEAR -->"
|
||||||
|
|
||||||
|
def _inject_marker(self, content):
|
||||||
|
return content + marker
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
claude.__class__, "post_process_command_content", _inject_marker
|
||||||
|
)
|
||||||
|
|
||||||
|
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||||
|
registrar.register_commands(
|
||||||
|
"claude", commands, "test-ext", ext, tmp_path
|
||||||
|
)
|
||||||
|
|
||||||
|
skill_file = (
|
||||||
|
tmp_path / ".claude" / "skills" / "speckit-test-review" / "SKILL.md"
|
||||||
|
)
|
||||||
|
assert skill_file.exists()
|
||||||
|
content = skill_file.read_text(encoding="utf-8")
|
||||||
|
assert marker not in content
|
||||||
|
|
||||||
|
def test_skills_agent_method_never_called(
|
||||||
|
self, tmp_path, registrar, ext_dir
|
||||||
|
):
|
||||||
|
ext, cmd_dir = ext_dir
|
||||||
|
_write_cmd(cmd_dir)
|
||||||
|
|
||||||
|
from specify_cli.integrations import get_integration
|
||||||
|
|
||||||
|
claude = get_integration("claude")
|
||||||
|
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
claude.__class__, "post_process_command_content", wraps=claude.post_process_command_content
|
||||||
|
) as mock_method:
|
||||||
|
registrar.register_commands(
|
||||||
|
"claude", commands, "test-ext", ext, tmp_path
|
||||||
|
)
|
||||||
|
mock_method.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestExceptionPropagation:
|
||||||
|
def test_hook_exception_propagates(
|
||||||
|
self, tmp_path, registrar, ext_dir, monkeypatch
|
||||||
|
):
|
||||||
|
ext, cmd_dir = ext_dir
|
||||||
|
_write_cmd(cmd_dir)
|
||||||
|
|
||||||
|
from specify_cli.integrations import get_integration
|
||||||
|
|
||||||
|
opencode = get_integration("opencode")
|
||||||
|
|
||||||
|
def _raise(self, content):
|
||||||
|
raise RuntimeError("Hook failed")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
opencode.__class__, "post_process_command_content", _raise
|
||||||
|
)
|
||||||
|
|
||||||
|
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||||
|
with pytest.raises(RuntimeError, match="Hook failed"):
|
||||||
|
registrar.register_commands(
|
||||||
|
"opencode", commands, "test-ext", ext, tmp_path
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegressionPlainTemplate:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"agent,path_pattern",
|
||||||
|
[
|
||||||
|
("claude", ".claude/skills/speckit-test-plain/SKILL.md"),
|
||||||
|
("opencode", ".opencode/commands/speckit.test.plain.md"),
|
||||||
|
],
|
||||||
|
ids=["skills", "markdown"],
|
||||||
|
)
|
||||||
|
def test_plain_template_unchanged(
|
||||||
|
self, tmp_path, registrar, ext_dir, agent, path_pattern
|
||||||
|
):
|
||||||
|
ext, cmd_dir = ext_dir
|
||||||
|
body_text = "This is a plain command with no special content.\n"
|
||||||
|
_write_cmd(cmd_dir, name="plain.md", body=body_text)
|
||||||
|
|
||||||
|
commands = [{"name": "speckit.test.plain", "file": "commands/plain.md"}]
|
||||||
|
registrar.register_commands(
|
||||||
|
agent, commands, "test-ext", ext, tmp_path
|
||||||
|
)
|
||||||
|
|
||||||
|
output_file = tmp_path / path_pattern
|
||||||
|
assert output_file.exists(), f"Output file missing for {agent}"
|
||||||
|
content = output_file.read_text(encoding="utf-8")
|
||||||
|
assert body_text.strip() in content, f"Body text missing in {agent} output"
|
||||||
@@ -4538,6 +4538,27 @@ class TestBundledPresetLocator:
|
|||||||
assert "got https://" not in output
|
assert "got https://" not in output
|
||||||
open_url.assert_not_called()
|
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):
|
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."""
|
"""Redirect rejection message covers hostless HTTPS, not only non-HTTPS URLs."""
|
||||||
import typer
|
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
|
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
|
@requires_bash
|
||||||
def test_setup_plan_numbered_branch_works_with_feature_json(
|
def test_setup_plan_numbered_branch_works_with_feature_json(
|
||||||
plan_repo: Path,
|
plan_repo: Path,
|
||||||
|
|||||||
@@ -466,6 +466,68 @@ def test_bash_command_hint_preserves_hyphens_inside_segments(tasks_repo: Path) -
|
|||||||
assert result.stdout.strip() == "/speckit.jira.sync-status"
|
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
|
@requires_bash
|
||||||
def test_bash_command_hint_caches_invoke_separator_per_process(tasks_repo: Path) -> None:
|
def test_bash_command_hint_caches_invoke_separator_per_process(tasks_repo: Path) -> None:
|
||||||
_write_integration_state(tasks_repo, "claude", "-")
|
_write_integration_state(tasks_repo, "claude", "-")
|
||||||
|
|||||||
@@ -322,3 +322,87 @@ class TestWorkflowRunWithoutProject:
|
|||||||
|
|
||||||
assert result.exit_code != 0
|
assert result.exit_code != 0
|
||||||
assert ".specify path exists but is not a directory" in result.output
|
assert ".specify path exists but is not a directory" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
class TestWorkflowRunJsonErrorStream:
|
||||||
|
"""Under --json, error text must go to stderr so stdout stays parseable."""
|
||||||
|
|
||||||
|
def _bad_workflow(self, tmp_path):
|
||||||
|
wf = tmp_path / "bad.yml"
|
||||||
|
wf.write_text(
|
||||||
|
yaml.dump(
|
||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"workflow": {
|
||||||
|
"id": "bad-wf",
|
||||||
|
"name": "Bad",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "fails validation",
|
||||||
|
},
|
||||||
|
# shell step missing required 'run' -> validation error
|
||||||
|
"steps": [{"id": "s", "type": "shell"}],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return wf
|
||||||
|
|
||||||
|
def test_run_json_validation_error_not_on_stdout(self, tmp_path):
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
from specify_cli import app
|
||||||
|
|
||||||
|
wf = self._bad_workflow(tmp_path)
|
||||||
|
runner = CliRunner()
|
||||||
|
old = os.getcwd()
|
||||||
|
try:
|
||||||
|
os.chdir(tmp_path)
|
||||||
|
result = runner.invoke(
|
||||||
|
app, ["workflow", "run", str(wf), "--json"], catch_exceptions=False
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
os.chdir(old)
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
# stdout must carry only JSON (here: nothing) — never human error text.
|
||||||
|
assert "validation failed" not in result.stdout
|
||||||
|
assert "Error" not in result.stdout
|
||||||
|
# The message is routed to stderr instead.
|
||||||
|
assert "validation failed" in result.stderr
|
||||||
|
|
||||||
|
def test_run_json_invalid_input_not_on_stdout(self, tmp_path):
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
from specify_cli import app
|
||||||
|
|
||||||
|
# A valid single-shell workflow so we get past load/validate to
|
||||||
|
# _parse_input_values, which rejects the malformed --input.
|
||||||
|
wf = tmp_path / "ok.yml"
|
||||||
|
wf.write_text(
|
||||||
|
yaml.dump(
|
||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"workflow": {
|
||||||
|
"id": "ok-wf",
|
||||||
|
"name": "OK",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "x",
|
||||||
|
},
|
||||||
|
"steps": [{"id": "s", "type": "shell", "run": "echo hi"}],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
runner = CliRunner()
|
||||||
|
old = os.getcwd()
|
||||||
|
try:
|
||||||
|
os.chdir(tmp_path)
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["workflow", "run", str(wf), "--json", "--input", "no-equals"],
|
||||||
|
catch_exceptions=False,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
os.chdir(old)
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "Invalid input format" not in result.stdout
|
||||||
|
assert "Invalid input format" in result.stderr
|
||||||
|
|||||||
@@ -601,6 +601,73 @@ class TestExpressions:
|
|||||||
):
|
):
|
||||||
evaluate_expression("{{ inputs.tags | map }}", ctx)
|
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):
|
def test_condition_evaluation(self):
|
||||||
from specify_cli.workflows.expressions import evaluate_condition
|
from specify_cli.workflows.expressions import evaluate_condition
|
||||||
from specify_cli.workflows.base import StepContext
|
from specify_cli.workflows.base import StepContext
|
||||||
@@ -1266,6 +1333,126 @@ class TestShellStep:
|
|||||||
errors = step.validate({"id": "test"})
|
errors = step.validate({"id": "test"})
|
||||||
assert any("missing 'run'" in e for e in errors)
|
assert any("missing 'run'" in e for e in errors)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad_run", [None, ["echo", "hi"], 42])
|
||||||
|
def test_validate_rejects_non_string_run(self, bad_run):
|
||||||
|
"""A non-string 'run' must be rejected at validation.
|
||||||
|
|
||||||
|
execute() str()-coerces run and invokes it under shell=True, so a
|
||||||
|
null or list run would otherwise run the Python repr as a command.
|
||||||
|
"""
|
||||||
|
from specify_cli.workflows.steps.shell import ShellStep
|
||||||
|
|
||||||
|
step = ShellStep()
|
||||||
|
errors = step.validate({"id": "s", "run": bad_run})
|
||||||
|
assert any("'run' must be a string" in e for e in errors)
|
||||||
|
|
||||||
|
def test_validate_accepts_string_and_expression_run(self):
|
||||||
|
from specify_cli.workflows.steps.shell import ShellStep
|
||||||
|
|
||||||
|
step = ShellStep()
|
||||||
|
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):
|
def test_output_format_json_exposes_data(self, tmp_path):
|
||||||
from specify_cli.workflows.steps.shell import ShellStep
|
from specify_cli.workflows.steps.shell import ShellStep
|
||||||
@@ -2182,6 +2369,27 @@ class TestFanInStep:
|
|||||||
errors = step.validate({"id": "test", "wait_for": "not-a-list"})
|
errors = step.validate({"id": "test", "wait_for": "not-a-list"})
|
||||||
assert any("non-empty list" in e for e in errors)
|
assert any("non-empty list" in e for e in errors)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad_output", [["{{ fan_in.results }}"], "{{ x }}", 42])
|
||||||
|
def test_validate_rejects_non_mapping_output(self, bad_output):
|
||||||
|
"""A non-mapping 'output' must be rejected: execute() would otherwise
|
||||||
|
silently coerce it to {} and drop the declared aggregation keys."""
|
||||||
|
from specify_cli.workflows.steps.fan_in import FanInStep
|
||||||
|
|
||||||
|
step = FanInStep()
|
||||||
|
errors = step.validate(
|
||||||
|
{"id": "j", "wait_for": ["a"], "output": bad_output}
|
||||||
|
)
|
||||||
|
assert any("'output' must be a mapping" in e for e in errors)
|
||||||
|
|
||||||
|
def test_validate_accepts_mapping_or_absent_output(self):
|
||||||
|
from specify_cli.workflows.steps.fan_in import FanInStep
|
||||||
|
|
||||||
|
step = FanInStep()
|
||||||
|
assert step.validate(
|
||||||
|
{"id": "j", "wait_for": ["a"], "output": {"joined": "{{ x }}"}}
|
||||||
|
) == []
|
||||||
|
assert step.validate({"id": "j", "wait_for": ["a"]}) == []
|
||||||
|
|
||||||
|
|
||||||
class TestFanOutConcurrency:
|
class TestFanOutConcurrency:
|
||||||
"""Fan-out honors max_concurrency (WorkflowEngine._run_fan_out)."""
|
"""Fan-out honors max_concurrency (WorkflowEngine._run_fan_out)."""
|
||||||
@@ -5552,6 +5760,23 @@ class TestWorkflowRemoveGuard:
|
|||||||
|
|
||||||
|
|
||||||
class TestWorkflowAddSymlinkGuard:
|
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")
|
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||||
def test_add_refuses_symlinked_specify(self, temp_dir, monkeypatch):
|
def test_add_refuses_symlinked_specify(self, temp_dir, monkeypatch):
|
||||||
"""workflow add must refuse a symlinked .specify (writes could escape root)."""
|
"""workflow add must refuse a symlinked .specify (writes could escape root)."""
|
||||||
|
|||||||
@@ -131,3 +131,87 @@ def test_preset_install_preserves_explicit_zero_priority(tmp_path: Path, monkeyp
|
|||||||
|
|
||||||
# An explicit priority of 0 must be passed through, not replaced by default.
|
# An explicit priority of 0 must be passed through, not replaced by default.
|
||||||
assert calls["priority"] == 0
|
assert calls["priority"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _write_manifest(path: Path, root_key: str, version: str) -> Path:
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
(path / f"{root_key}.yml").write_text(
|
||||||
|
f"{root_key}:\n id: x\n version: {version}\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def test_bundled_extension_pin_mismatch_refuses(tmp_path: Path, monkeypatch):
|
||||||
|
"""A bundled extension whose version != the manifest pin must be refused
|
||||||
|
(the bundled path previously skipped the pin the catalog path enforces)."""
|
||||||
|
import specify_cli._assets as assets
|
||||||
|
from specify_cli.extensions import ExtensionManager
|
||||||
|
|
||||||
|
bundled = _write_manifest(tmp_path / "ext", "extension", "1.0.0")
|
||||||
|
monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled)
|
||||||
|
called: list = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ExtensionManager, "install_from_directory",
|
||||||
|
lambda self, *a, **k: called.append(a),
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = primitive_manager("extensions", tmp_path, allow_network=False)
|
||||||
|
with pytest.raises(BundlerError, match="pinned to version 2.0.0"):
|
||||||
|
manager.install(ComponentRef(kind="extensions", id="my-ext", version="2.0.0"))
|
||||||
|
assert called == [] # install must not proceed
|
||||||
|
|
||||||
|
|
||||||
|
def test_bundled_extension_pin_match_installs(tmp_path: Path, monkeypatch):
|
||||||
|
import specify_cli._assets as assets
|
||||||
|
from specify_cli.extensions import ExtensionManager
|
||||||
|
|
||||||
|
bundled = _write_manifest(tmp_path / "ext", "extension", "1.0.0")
|
||||||
|
monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled)
|
||||||
|
called: list = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ExtensionManager, "install_from_directory",
|
||||||
|
lambda self, *a, **k: called.append(a),
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = primitive_manager("extensions", tmp_path, allow_network=False)
|
||||||
|
# matching pin, and unpinned, both install cleanly
|
||||||
|
manager.install(ComponentRef(kind="extensions", id="my-ext", version="1.0.0"))
|
||||||
|
manager.install(ComponentRef(kind="extensions", id="my-ext", version=None))
|
||||||
|
assert len(called) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_bundled_preset_pin_mismatch_refuses(tmp_path: Path, monkeypatch):
|
||||||
|
import specify_cli._assets as assets
|
||||||
|
from specify_cli.presets import PresetManager
|
||||||
|
|
||||||
|
bundled = _write_manifest(tmp_path / "preset", "preset", "1.0.0")
|
||||||
|
monkeypatch.setattr(assets, "_locate_bundled_preset", lambda cid: bundled)
|
||||||
|
called: list = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
PresetManager, "install_from_directory",
|
||||||
|
lambda self, *a, **k: called.append(a),
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = primitive_manager("presets", tmp_path, allow_network=False)
|
||||||
|
with pytest.raises(BundlerError, match="pinned to version 2.0.0"):
|
||||||
|
manager.install(ComponentRef(kind="presets", id="my-preset", version="2.0.0"))
|
||||||
|
assert called == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_bundled_preset_pin_match_installs(tmp_path: Path, monkeypatch):
|
||||||
|
import specify_cli._assets as assets
|
||||||
|
from specify_cli.presets import PresetManager
|
||||||
|
|
||||||
|
bundled = _write_manifest(tmp_path / "preset", "preset", "1.0.0")
|
||||||
|
monkeypatch.setattr(assets, "_locate_bundled_preset", lambda cid: bundled)
|
||||||
|
called: list = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
PresetManager, "install_from_directory",
|
||||||
|
lambda self, *a, **k: called.append(a),
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = primitive_manager("presets", tmp_path, allow_network=False)
|
||||||
|
# matching pin, and unpinned, both proceed to install
|
||||||
|
manager.install(ComponentRef(kind="presets", id="my-preset", version="1.0.0"))
|
||||||
|
manager.install(ComponentRef(kind="presets", id="my-preset", version=None))
|
||||||
|
assert len(called) == 2
|
||||||
|
|||||||
Reference in New Issue
Block a user