Skip to content

Commit bd4d268

Browse files
committed
fix(goose): Declare args parameter in generated recipes
1 parent 0d8685a commit bd4d268

2 files changed

Lines changed: 59 additions & 14 deletions

File tree

src/specify_cli/integrations/base.py

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
from pathlib import Path
2121
from typing import TYPE_CHECKING, Any
2222

23+
import yaml
24+
2325
if TYPE_CHECKING:
2426
from .manifest import IntegrationManifest
2527

@@ -606,6 +608,7 @@ def remove_context_section(self, project_root: Path) -> bool:
606608
# For .mdc files, treat Speckit-generated frontmatter-only content as empty
607609
if ctx_path.suffix == ".mdc":
608610
import re
611+
609612
# Delete the file if only YAML frontmatter remains (no body content)
610613
frontmatter_only = re.match(
611614
r"^---\n.*?\n---\s*$", normalized, re.DOTALL
@@ -953,7 +956,6 @@ def _extract_description(content: str) -> str:
953956
and ``>``) keep their YAML semantics instead of being treated as
954957
raw text.
955958
"""
956-
import yaml
957959

958960
frontmatter_text, _ = TomlIntegration._split_frontmatter(content)
959961
if not frontmatter_text:
@@ -1140,7 +1142,6 @@ def command_filename(self, template_name: str) -> str:
11401142
@staticmethod
11411143
def _extract_frontmatter(content: str) -> dict[str, Any]:
11421144
"""Extract frontmatter as a dict from YAML frontmatter block."""
1143-
import yaml
11441145

11451146
if not content.startswith("---"):
11461147
return {}
@@ -1201,24 +1202,33 @@ def _human_title(identifier: str) -> str:
12011202
text = text[len("speckit.") :]
12021203
return text.replace(".", " ").replace("-", " ").replace("_", " ").title()
12031204

1204-
@staticmethod
1205-
def _render_yaml(title: str, description: str, body: str, source_id: str) -> str:
1206-
"""Render a YAML recipe file from title, description, and body.
1207-
1208-
Produces a Goose-compatible recipe with a literal block scalar
1209-
for the prompt content. Uses ``yaml.safe_dump()`` for the
1210-
header fields to ensure proper escaping.
1211-
"""
1212-
import yaml
12131205

1206+
@staticmethod
1207+
def _build_yaml_header(title: str, description: str) -> dict:
1208+
"""Build the base YAML header."""
12141209
header = {
12151210
"version": "1.0.0",
12161211
"title": title,
12171212
"description": description,
12181213
"author": {"contact": "spec-kit"},
1214+
"parameters": [
1215+
{
1216+
"key": "args",
1217+
"input_type": "string",
1218+
"requirement": "optional",
1219+
"default": "",
1220+
"description": "User input passed to the command.",
1221+
}
1222+
],
12191223
"extensions": [{"type": "builtin", "name": "developer"}],
12201224
"activities": ["Spec-Driven Development"],
12211225
}
1226+
return header
1227+
1228+
@classmethod
1229+
def _render_yaml(cls, title: str, description: str, body: str, source_id: str) -> str:
1230+
1231+
header = cls._build_yaml_header(title, description)
12221232

12231233
header_yaml = yaml.safe_dump(
12241234
header,
@@ -1227,12 +1237,20 @@ def _render_yaml(title: str, description: str, body: str, source_id: str) -> str
12271237
default_flow_style=False,
12281238
).strip()
12291239

1230-
# Indent each line for YAML block scalar
1240+
# Indent the body for YAML block scalar
12311241
indented = "\n".join(f" {line}" for line in body.split("\n"))
12321242

1233-
lines = [header_yaml, "prompt: |", indented, "", f"# Source: {source_id}"]
1243+
lines = [
1244+
header_yaml,
1245+
"prompt: |",
1246+
indented,
1247+
"",
1248+
f"# Source: {source_id}",
1249+
]
1250+
12341251
return "\n".join(lines) + "\n"
12351252

1253+
12361254
def setup(
12371255
self,
12381256
project_root: Path,
@@ -1391,7 +1409,6 @@ def setup(
13911409
template. Each SKILL.md has normalised frontmatter containing
13921410
``name``, ``description``, ``compatibility``, and ``metadata``.
13931411
"""
1394-
import yaml
13951412

13961413
templates = self.list_command_templates()
13971414
if not templates:

tests/integrations/test_integration_goose.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
"""Tests for GooseIntegration."""
22

3+
import yaml
4+
from specify_cli.integrations import get_integration
5+
from specify_cli.integrations.manifest import IntegrationManifest
6+
37
from .test_integration_base_yaml import YamlIntegrationTests
48

59

@@ -9,3 +13,27 @@ class TestGooseIntegration(YamlIntegrationTests):
913
COMMANDS_SUBDIR = "recipes"
1014
REGISTRAR_DIR = ".goose/recipes"
1115
CONTEXT_FILE = "AGENTS.md"
16+
17+
def test_setup_declares_args_parameter_for_args_prompt(self, tmp_path):
18+
# “If a generated Goose recipe uses {{args}} in its prompt, it
19+
# must declare a corresponding args parameter.”
20+
21+
integration = get_integration("goose")
22+
assert integration is not None
23+
24+
manifest = IntegrationManifest("goose", tmp_path)
25+
created = integration.setup(tmp_path, manifest, script_type="sh")
26+
27+
recipe_files = [path for path in created if path.suffix == ".yaml"]
28+
assert recipe_files
29+
30+
for recipe_file in recipe_files:
31+
data = yaml.safe_load(recipe_file.read_text(encoding="utf-8"))
32+
33+
if "{{args}}" not in data["prompt"]:
34+
continue
35+
36+
assert any(
37+
param.get("key") == "args"
38+
for param in data.get("parameters", [])
39+
), f"{recipe_file} uses {{args}} but does not declare args"

0 commit comments

Comments
 (0)