Skip to content

Commit ef16b21

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

3 files changed

Lines changed: 66 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: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Goose integration — Block's open source AI agent."""
22

3+
import yaml
4+
35
from ..base import YamlIntegration
46

57

@@ -19,3 +21,17 @@ class GooseIntegration(YamlIntegration):
1921
"extension": ".yaml",
2022
}
2123
context_file = "AGENTS.md"
24+
25+
@classmethod
26+
def _build_yaml_header(cls, title: str, description: str) -> dict:
27+
header = super()._build_yaml_header(title, description)
28+
header["parameters"] = [
29+
{
30+
"key": "args",
31+
"input_type": "string",
32+
"requirement": "optional",
33+
"default": "",
34+
"description": "User input passed to the command.",
35+
}
36+
]
37+
return header

tests/integrations/test_integration_goose.py

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

3+
import yaml
4+
from specify_cli.integrations import get_integration
5+
from specify_cli.integrations.base import YamlIntegration
6+
from specify_cli.integrations.manifest import IntegrationManifest
7+
38
from .test_integration_base_yaml import YamlIntegrationTests
49

510

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

0 commit comments

Comments
 (0)