Skip to content

Commit 68c6b48

Browse files
committed
fix: harden codex skill frontmatter and script fallback
1 parent f60ec37 commit 68c6b48

2 files changed

Lines changed: 109 additions & 5 deletions

File tree

src/specify_cli/agents.py

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,9 @@ def parse_frontmatter(content: str) -> tuple[dict, str]:
176176
except yaml.YAMLError:
177177
frontmatter = {}
178178

179+
if not isinstance(frontmatter, dict):
180+
frontmatter = {}
181+
179182
return frontmatter, body
180183

181184
@staticmethod
@@ -280,6 +283,9 @@ def render_skill_command(
280283
frontmatter shape used elsewhere in the project instead of the
281284
original command frontmatter.
282285
"""
286+
if not isinstance(frontmatter, dict):
287+
frontmatter = {}
288+
283289
if agent_name == "codex":
284290
body = self._resolve_codex_skill_placeholders(frontmatter, body, project_root)
285291

@@ -308,19 +314,39 @@ def _resolve_codex_skill_placeholders(frontmatter: dict, body: str, project_root
308314
except ImportError:
309315
return body
310316

311-
script_variant = load_init_options(project_root).get("script")
312-
if script_variant not in {"sh", "ps"}:
313-
return body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", "codex")
317+
if not isinstance(frontmatter, dict):
318+
frontmatter = {}
314319

315320
scripts = frontmatter.get("scripts", {}) or {}
316321
agent_scripts = frontmatter.get("agent_scripts", {}) or {}
322+
if not isinstance(scripts, dict):
323+
scripts = {}
324+
if not isinstance(agent_scripts, dict):
325+
agent_scripts = {}
317326

318-
script_command = scripts.get(script_variant)
327+
script_variant = load_init_options(project_root).get("script")
328+
if script_variant not in {"sh", "ps"}:
329+
fallback_order = []
330+
if "sh" in scripts or "sh" in agent_scripts:
331+
fallback_order.append("sh")
332+
if "ps" in scripts or "ps" in agent_scripts:
333+
fallback_order.append("ps")
334+
335+
for key in scripts:
336+
if key not in fallback_order:
337+
fallback_order.append(key)
338+
for key in agent_scripts:
339+
if key not in fallback_order:
340+
fallback_order.append(key)
341+
342+
script_variant = fallback_order[0] if fallback_order else None
343+
344+
script_command = scripts.get(script_variant) if script_variant else None
319345
if script_command:
320346
script_command = script_command.replace("{ARGS}", "$ARGUMENTS")
321347
body = body.replace("{SCRIPT}", script_command)
322348

323-
agent_script_command = agent_scripts.get(script_variant)
349+
agent_script_command = agent_scripts.get(script_variant) if script_variant else None
324350
if agent_script_command:
325351
agent_script_command = agent_script_command.replace("{ARGS}", "$ARGUMENTS")
326352
body = body.replace("{AGENT_SCRIPT}", agent_script_command)

tests/test_extensions.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,21 @@ def test_parse_frontmatter_no_frontmatter(self):
642642
assert frontmatter == {}
643643
assert body == content
644644

645+
def test_parse_frontmatter_non_mapping_returns_empty_dict(self):
646+
"""Non-mapping YAML frontmatter should not crash downstream renderers."""
647+
content = """---
648+
- item1
649+
- item2
650+
---
651+
652+
# Command body
653+
"""
654+
registrar = CommandRegistrar()
655+
frontmatter, body = registrar.parse_frontmatter(content)
656+
657+
assert frontmatter == {}
658+
assert "Command body" in body
659+
645660
def test_render_frontmatter(self):
646661
"""Test rendering frontmatter to YAML."""
647662
frontmatter = {
@@ -899,6 +914,69 @@ def test_codex_skill_alias_frontmatter_matches_alias_name(self, project_dir, tem
899914
assert "name: speckit-alias.cmd" in primary.read_text()
900915
assert "name: speckit-shortcut" in alias.read_text()
901916

917+
def test_codex_skill_registration_uses_fallback_script_variant_without_init_options(
918+
self, project_dir, temp_dir
919+
):
920+
"""Codex placeholder substitution should still work without init-options.json."""
921+
import yaml
922+
923+
ext_dir = temp_dir / "ext-script-fallback"
924+
ext_dir.mkdir()
925+
(ext_dir / "commands").mkdir()
926+
927+
manifest_data = {
928+
"schema_version": "1.0",
929+
"extension": {
930+
"id": "ext-script-fallback",
931+
"name": "Script fallback",
932+
"version": "1.0.0",
933+
"description": "Test",
934+
},
935+
"requires": {"speckit_version": ">=0.1.0"},
936+
"provides": {
937+
"commands": [
938+
{
939+
"name": "speckit.fallback.plan",
940+
"file": "commands/plan.md",
941+
}
942+
]
943+
},
944+
}
945+
with open(ext_dir / "extension.yml", "w") as f:
946+
yaml.dump(manifest_data, f)
947+
948+
(ext_dir / "commands" / "plan.md").write_text(
949+
"""---
950+
description: "Fallback scripted command"
951+
scripts:
952+
sh: scripts/bash/setup-plan.sh --json "{ARGS}"
953+
ps: scripts/powershell/setup-plan.ps1 -Json
954+
agent_scripts:
955+
sh: scripts/bash/update-agent-context.sh __AGENT__
956+
---
957+
958+
Run {SCRIPT}
959+
Then {AGENT_SCRIPT}
960+
"""
961+
)
962+
963+
# Intentionally do NOT create .specify/init-options.json
964+
skills_dir = project_dir / ".agents" / "skills"
965+
skills_dir.mkdir(parents=True)
966+
967+
manifest = ExtensionManifest(ext_dir / "extension.yml")
968+
registrar = CommandRegistrar()
969+
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
970+
971+
skill_file = skills_dir / "speckit-fallback.plan" / "SKILL.md"
972+
assert skill_file.exists()
973+
974+
content = skill_file.read_text()
975+
assert "{SCRIPT}" not in content
976+
assert "{AGENT_SCRIPT}" not in content
977+
assert 'scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
978+
assert "scripts/bash/update-agent-context.sh codex" in content
979+
902980
def test_register_commands_for_copilot(self, extension_dir, project_dir):
903981
"""Test registering commands for Copilot agent with .agent.md extension."""
904982
# Create .github/agents directory (Copilot project)

0 commit comments

Comments
 (0)