From ef16b21b7d01cebf7b830244d5b777dd299aed6f Mon Sep 17 00:00:00 2001 From: Nate Nichols Date: Sat, 2 May 2026 19:35:34 -0400 Subject: [PATCH] fix(goose): declare args parameter in generated recipes --- src/specify_cli/integrations/base.py | 33 ++++++++++++------- .../integrations/goose/__init__.py | 16 +++++++++ tests/integrations/test_integration_goose.py | 29 ++++++++++++++++ 3 files changed, 66 insertions(+), 12 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index f3b74b0c05..68a2943565 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -598,6 +598,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 @@ -1193,17 +1194,11 @@ 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 - header = { + @staticmethod + def _build_yaml_header(title: str, description: str) -> dict: + """Build the base YAML header.""" + return { "version": "1.0.0", "title": title, "description": description, @@ -1212,6 +1207,12 @@ def _render_yaml(title: str, description: str, body: str, source_id: str) -> str "activities": ["Spec-Driven Development"], } + @classmethod + def _render_yaml(cls, title: str, description: str, body: str, source_id: str) -> str: + import yaml + + header = cls._build_yaml_header(title, description) + header_yaml = yaml.safe_dump( header, sort_keys=False, @@ -1219,12 +1220,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, diff --git a/src/specify_cli/integrations/goose/__init__.py b/src/specify_cli/integrations/goose/__init__.py index 0fc4d9d57a..0f84cb0d64 100644 --- a/src/specify_cli/integrations/goose/__init__.py +++ b/src/specify_cli/integrations/goose/__init__.py @@ -1,5 +1,7 @@ """Goose integration — Block's open source AI agent.""" +import yaml + from ..base import YamlIntegration @@ -19,3 +21,17 @@ class GooseIntegration(YamlIntegration): "extension": ".yaml", } context_file = "AGENTS.md" + + @classmethod + def _build_yaml_header(cls, title: str, description: str) -> dict: + header = super()._build_yaml_header(title, description) + header["parameters"] = [ + { + "key": "args", + "input_type": "string", + "requirement": "optional", + "default": "", + "description": "User input passed to the command.", + } + ] + return header diff --git a/tests/integrations/test_integration_goose.py b/tests/integrations/test_integration_goose.py index 6483666f36..ce4011d292 100644 --- a/tests/integrations/test_integration_goose.py +++ b/tests/integrations/test_integration_goose.py @@ -1,5 +1,10 @@ """Tests for GooseIntegration.""" +import yaml +from specify_cli.integrations import get_integration +from specify_cli.integrations.base import YamlIntegration +from specify_cli.integrations.manifest import IntegrationManifest + from .test_integration_base_yaml import YamlIntegrationTests @@ -9,3 +14,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"