diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index c46340ddff..5009b67fe7 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -20,6 +20,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Any +import yaml + if TYPE_CHECKING: from .manifest import IntegrationManifest @@ -606,6 +608,7 @@ def remove_context_section(self, project_root: Path) -> bool: # For .mdc files, treat Speckit-generated frontmatter-only content as empty if ctx_path.suffix == ".mdc": import re + # Delete the file if only YAML frontmatter remains (no body content) frontmatter_only = re.match( r"^---\n.*?\n---\s*$", normalized, re.DOTALL @@ -953,7 +956,6 @@ def _extract_description(content: str) -> str: and ``>``) keep their YAML semantics instead of being treated as raw text. """ - import yaml frontmatter_text, _ = TomlIntegration._split_frontmatter(content) if not frontmatter_text: @@ -1140,7 +1142,6 @@ def command_filename(self, template_name: str) -> str: @staticmethod def _extract_frontmatter(content: str) -> dict[str, Any]: """Extract frontmatter as a dict from YAML frontmatter block.""" - import yaml if not content.startswith("---"): return {} @@ -1201,24 +1202,33 @@ def _human_title(identifier: str) -> str: text = text[len("speckit.") :] return text.replace(".", " ").replace("-", " ").replace("_", " ").title() - @staticmethod - def _render_yaml(title: str, description: str, body: str, source_id: str) -> str: - """Render a YAML recipe file from title, description, and body. - - Produces a Goose-compatible recipe with a literal block scalar - for the prompt content. Uses ``yaml.safe_dump()`` for the - header fields to ensure proper escaping. - """ - import yaml + @staticmethod + def _build_yaml_header(title: str, description: str) -> dict: + """Build the base YAML header.""" header = { "version": "1.0.0", "title": title, "description": description, "author": {"contact": "spec-kit"}, + "parameters": [ + { + "key": "args", + "input_type": "string", + "requirement": "optional", + "default": "", + "description": "User input passed to the command.", + } + ], "extensions": [{"type": "builtin", "name": "developer"}], "activities": ["Spec-Driven Development"], } + return header + + @classmethod + def _render_yaml(cls, title: str, description: str, body: str, source_id: str) -> str: + + header = cls._build_yaml_header(title, description) header_yaml = yaml.safe_dump( header, @@ -1227,12 +1237,20 @@ def _render_yaml(title: str, description: str, body: str, source_id: str) -> str default_flow_style=False, ).strip() - # Indent each line for YAML block scalar + # Indent the body for YAML block scalar indented = "\n".join(f" {line}" for line in body.split("\n")) - lines = [header_yaml, "prompt: |", indented, "", f"# Source: {source_id}"] + lines = [ + header_yaml, + "prompt: |", + indented, + "", + f"# Source: {source_id}", + ] + return "\n".join(lines) + "\n" + def setup( self, project_root: Path, @@ -1391,7 +1409,6 @@ def setup( template. Each SKILL.md has normalised frontmatter containing ``name``, ``description``, ``compatibility``, and ``metadata``. """ - import yaml templates = self.list_command_templates() if not templates: diff --git a/tests/integrations/test_integration_goose.py b/tests/integrations/test_integration_goose.py index 6483666f36..916303d49d 100644 --- a/tests/integrations/test_integration_goose.py +++ b/tests/integrations/test_integration_goose.py @@ -1,5 +1,9 @@ """Tests for GooseIntegration.""" +import yaml +from specify_cli.integrations import get_integration +from specify_cli.integrations.manifest import IntegrationManifest + from .test_integration_base_yaml import YamlIntegrationTests @@ -9,3 +13,27 @@ class TestGooseIntegration(YamlIntegrationTests): COMMANDS_SUBDIR = "recipes" REGISTRAR_DIR = ".goose/recipes" CONTEXT_FILE = "AGENTS.md" + + def test_setup_declares_args_parameter_for_args_prompt(self, tmp_path): + # “If a generated Goose recipe uses {{args}} in its prompt, it + # must declare a corresponding args parameter.” + + integration = get_integration("goose") + assert integration is not None + + manifest = IntegrationManifest("goose", tmp_path) + created = integration.setup(tmp_path, manifest, script_type="sh") + + recipe_files = [path for path in created if path.suffix == ".yaml"] + assert recipe_files + + for recipe_file in recipe_files: + data = yaml.safe_load(recipe_file.read_text(encoding="utf-8")) + + if "{{args}}" not in data["prompt"]: + continue + + assert any( + param.get("key") == "args" + for param in data.get("parameters", []) + ), f"{recipe_file} uses {{args}} but does not declare args"