Skip to content

Commit 734efdf

Browse files
committed
Guard non-dict init options
1 parent ac580bd commit 734efdf

4 files changed

Lines changed: 94 additions & 1 deletion

File tree

src/specify_cli/agents.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,11 @@ def resolve_skill_placeholders(agent_name: str, frontmatter: dict, body: str, pr
387387
if not isinstance(agent_scripts, dict):
388388
agent_scripts = {}
389389

390-
script_variant = load_init_options(project_root).get("script")
390+
init_opts = load_init_options(project_root)
391+
if not isinstance(init_opts, dict):
392+
init_opts = {}
393+
394+
script_variant = init_opts.get("script")
391395
if script_variant not in {"sh", "ps"}:
392396
fallback_order = []
393397
default_variant = "ps" if platform.system().lower().startswith("win") else "sh"

src/specify_cli/presets.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,8 @@ def _get_skills_dir(self) -> Optional[Path]:
573573
from . import load_init_options, _get_skills_dir
574574

575575
opts = load_init_options(self.project_root)
576+
if not isinstance(opts, dict):
577+
opts = {}
576578
agent = opts.get("ai")
577579
if not isinstance(agent, str) or not agent:
578580
return None
@@ -699,6 +701,8 @@ def _register_skills(
699701
from .agents import CommandRegistrar
700702

701703
init_opts = load_init_options(self.project_root)
704+
if not isinstance(init_opts, dict):
705+
init_opts = {}
702706
selected_ai = init_opts.get("ai")
703707
if not isinstance(selected_ai, str):
704708
return []
@@ -795,6 +799,8 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None:
795799
# Locate core command templates from the project's installed templates
796800
core_templates_dir = self.project_root / ".specify" / "templates" / "commands"
797801
init_opts = load_init_options(self.project_root)
802+
if not isinstance(init_opts, dict):
803+
init_opts = {}
798804
selected_ai = init_opts.get("ai")
799805
registrar = CommandRegistrar()
800806
extension_restore_index = self._build_extension_skill_restore_index()

tests/test_extensions.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1141,6 +1141,62 @@ def test_codex_skill_registration_uses_fallback_script_variant_without_init_opti
11411141
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
11421142
assert ".specify/scripts/bash/update-agent-context.sh codex" in content
11431143

1144+
def test_codex_skill_registration_handles_non_dict_init_options(
1145+
self, project_dir, temp_dir
1146+
):
1147+
"""Non-dict init-options payloads should not crash skill placeholder resolution."""
1148+
import yaml
1149+
1150+
ext_dir = temp_dir / "ext-script-list-init"
1151+
ext_dir.mkdir()
1152+
(ext_dir / "commands").mkdir()
1153+
1154+
manifest_data = {
1155+
"schema_version": "1.0",
1156+
"extension": {
1157+
"id": "ext-script-list-init",
1158+
"name": "List init options",
1159+
"version": "1.0.0",
1160+
"description": "Test",
1161+
},
1162+
"requires": {"speckit_version": ">=0.1.0"},
1163+
"provides": {
1164+
"commands": [
1165+
{
1166+
"name": "speckit.list.plan",
1167+
"file": "commands/plan.md",
1168+
}
1169+
]
1170+
},
1171+
}
1172+
with open(ext_dir / "extension.yml", "w") as f:
1173+
yaml.dump(manifest_data, f)
1174+
1175+
(ext_dir / "commands" / "plan.md").write_text(
1176+
"""---
1177+
description: "List init scripted command"
1178+
scripts:
1179+
sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}"
1180+
---
1181+
1182+
Run {SCRIPT}
1183+
"""
1184+
)
1185+
1186+
init_options = project_dir / ".specify" / "init-options.json"
1187+
init_options.parent.mkdir(parents=True, exist_ok=True)
1188+
init_options.write_text("[]")
1189+
1190+
skills_dir = project_dir / ".agents" / "skills"
1191+
skills_dir.mkdir(parents=True)
1192+
1193+
manifest = ExtensionManifest(ext_dir / "extension.yml")
1194+
registrar = CommandRegistrar()
1195+
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
1196+
1197+
content = (skills_dir / "speckit-list-plan" / "SKILL.md").read_text()
1198+
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
1199+
11441200
def test_codex_skill_registration_fallback_prefers_powershell_on_windows(
11451201
self, project_dir, temp_dir, monkeypatch
11461202
):

tests/test_presets.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2005,6 +2005,16 @@ def test_get_skills_dir_returns_none_for_non_string_ai(self, project_dir):
20052005

20062006
assert manager._get_skills_dir() is None
20072007

2008+
def test_get_skills_dir_returns_none_for_non_dict_init_options(self, project_dir):
2009+
"""Corrupted non-dict init-options payloads should fail closed."""
2010+
init_options = project_dir / ".specify" / "init-options.json"
2011+
init_options.parent.mkdir(parents=True, exist_ok=True)
2012+
init_options.write_text("[]")
2013+
2014+
manager = PresetManager(project_dir)
2015+
2016+
assert manager._get_skills_dir() is None
2017+
20082018
def test_skill_not_updated_without_init_options(self, project_dir, temp_dir):
20092019
"""When no init-options.json exists, preset install should not touch skills."""
20102020
skills_dir = project_dir / ".claude" / "skills"
@@ -2333,6 +2343,23 @@ def test_kimi_preset_skill_override_resolves_script_placeholders(self, project_d
23332343
assert ".specify/memory/constitution.md" in content
23342344
assert "for kimi" in content
23352345

2346+
def test_preset_skill_registration_handles_non_dict_init_options(self, project_dir, temp_dir):
2347+
"""Non-dict init-options payloads should not crash preset install/remove flows."""
2348+
init_options = project_dir / ".specify" / "init-options.json"
2349+
init_options.parent.mkdir(parents=True, exist_ok=True)
2350+
init_options.write_text("[]")
2351+
2352+
skills_dir = project_dir / ".claude" / "skills"
2353+
self._create_skill(skills_dir, "speckit-specify", body="untouched")
2354+
(project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True)
2355+
2356+
manager = PresetManager(project_dir)
2357+
self_test_dir = Path(__file__).parent.parent / "presets" / "self-test"
2358+
manager.install_from_directory(self_test_dir, "0.1.5")
2359+
2360+
content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
2361+
assert "untouched" in content
2362+
23362363

23372364
class TestPresetSetPriority:
23382365
"""Test preset set-priority CLI command."""

0 commit comments

Comments
 (0)