mirror of
https://github.com/github/spec-kit.git
synced 2026-07-08 15:06:10 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d14ccb8641 | ||
|
|
d5ba062eab | ||
|
|
12faf7b5b5 | ||
|
|
220e6fcc4e | ||
|
|
fb796c2a39 | ||
|
|
0151d239b5 | ||
|
|
f764270d06 | ||
|
|
7839acce86 | ||
|
|
1935cf7e48 | ||
|
|
d4e7d2b888 | ||
|
|
abaed10d00 |
15
CHANGELOG.md
15
CHANGELOG.md
@@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
<!-- insert new changelog below this comment -->
|
<!-- insert new changelog below this comment -->
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ 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) |
|
||||||
@@ -86,6 +86,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 +107,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) |
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -2396,6 +2396,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 +3119,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 +3131,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 +3154,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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "specify-cli"
|
name = "specify-cli"
|
||||||
version = "0.12.6"
|
version = "0.12.7"
|
||||||
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"
|
||||||
|
|||||||
@@ -706,6 +706,17 @@ class CommandRegistrar:
|
|||||||
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 +777,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -123,6 +123,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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -90,6 +90,16 @@ 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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
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"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1266,6 +1266,26 @@ 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_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 +2202,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)."""
|
||||||
|
|||||||
Reference in New Issue
Block a user