Skip to content

Commit c9b2830

Browse files
committed
fix(goose): declare args parameter in generated recipes
1 parent 9483e5c commit c9b2830

3 files changed

Lines changed: 63 additions & 12 deletions

File tree

src/specify_cli/integrations/base.py

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,7 @@ def remove_context_section(self, project_root: Path) -> bool:
598598
# For .mdc files, treat Speckit-generated frontmatter-only content as empty
599599
if ctx_path.suffix == ".mdc":
600600
import re
601+
601602
# Delete the file if only YAML frontmatter remains (no body content)
602603
frontmatter_only = re.match(
603604
r"^---\n.*?\n---\s*$", normalized, re.DOTALL
@@ -1193,17 +1194,11 @@ def _human_title(identifier: str) -> str:
11931194
text = text[len("speckit.") :]
11941195
return text.replace(".", " ").replace("-", " ").replace("_", " ").title()
11951196

1196-
@staticmethod
1197-
def _render_yaml(title: str, description: str, body: str, source_id: str) -> str:
1198-
"""Render a YAML recipe file from title, description, and body.
1199-
1200-
Produces a Goose-compatible recipe with a literal block scalar
1201-
for the prompt content. Uses ``yaml.safe_dump()`` for the
1202-
header fields to ensure proper escaping.
1203-
"""
1204-
import yaml
12051197

1206-
header = {
1198+
@staticmethod
1199+
def _build_yaml_header(title: str, description: str) -> dict:
1200+
"""Build the base YAML header."""
1201+
return {
12071202
"version": "1.0.0",
12081203
"title": title,
12091204
"description": description,
@@ -1212,19 +1207,33 @@ def _render_yaml(title: str, description: str, body: str, source_id: str) -> str
12121207
"activities": ["Spec-Driven Development"],
12131208
}
12141209

1210+
@classmethod
1211+
def _render_yaml(cls, title: str, description: str, body: str, source_id: str) -> str:
1212+
import yaml
1213+
1214+
header = cls._build_yaml_header(title, description)
1215+
12151216
header_yaml = yaml.safe_dump(
12161217
header,
12171218
sort_keys=False,
12181219
allow_unicode=True,
12191220
default_flow_style=False,
12201221
).strip()
12211222

1222-
# Indent each line for YAML block scalar
1223+
# Indent the body for YAML block scalar
12231224
indented = "\n".join(f" {line}" for line in body.split("\n"))
12241225

1225-
lines = [header_yaml, "prompt: |", indented, "", f"# Source: {source_id}"]
1226+
lines = [
1227+
header_yaml,
1228+
"prompt: |",
1229+
indented,
1230+
"",
1231+
f"# Source: {source_id}",
1232+
]
1233+
12261234
return "\n".join(lines) + "\n"
12271235

1236+
12281237
def setup(
12291238
self,
12301239
project_root: Path,

src/specify_cli/integrations/goose/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,17 @@ class GooseIntegration(YamlIntegration):
1919
"extension": ".yaml",
2020
}
2121
context_file = "AGENTS.md"
22+
23+
@classmethod
24+
def _build_yaml_header(cls, title: str, description: str) -> dict:
25+
header = super()._build_yaml_header(title, description)
26+
header["parameters"] = [
27+
{
28+
"key": "args",
29+
"input_type": "string",
30+
"requirement": "optional",
31+
"default": "",
32+
"description": "User input passed to the command.",
33+
}
34+
]
35+
return header

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)