diff --git a/raven/config/update.py b/raven/config/update.py index 8ee882ec5..1a7917dd2 100644 --- a/raven/config/update.py +++ b/raven/config/update.py @@ -248,9 +248,15 @@ def _apply(_text: str | None) -> tuple[str | None, tuple[list[str], bool]]: sf_key = "skillForge" elif isinstance(data.get("skill_forge"), dict): sf_key = "skill_forge" + elif "skill_forge" in data and "skillForge" not in data: + sf_key = "skill_forge" else: sf_key = "skillForge" - section = data.setdefault(sf_key, {}) + section = data.get(sf_key) + if section is None: + section = data[sf_key] = {} + elif not isinstance(section, dict): + raise ValueError(f"{sf_key} must be an object or null") current = [str(x) for x in (section.get("blocklist") or [])] lowered = {x.casefold() for x in current} if blocked: diff --git a/tests/test_cli_skill_commands.py b/tests/test_cli_skill_commands.py index 616eb5452..1614cb606 100644 --- a/tests/test_cli_skill_commands.py +++ b/tests/test_cli_skill_commands.py @@ -207,9 +207,35 @@ def test_skill_unblock_removes_from_config(tmp_config: Path) -> None: runner.invoke(app, ["skill", "block", "other"]) r = runner.invoke(app, ["skill", "unblock", "Tag-Memory"]) assert r.exit_code == 0 + assert "skillForge" not in r.stdout assert _read_blocklist(tmp_config) == ["other"] +@pytest.mark.parametrize("spelling", ["skillForge", "skill_forge"]) +def test_skill_block_and_unblock_accept_a_null_section(tmp_config: Path, spelling: str) -> None: + import json + + from raven.config.loader import load_config + from raven.config.raven import load_raven_config + + tmp_config.write_text(json.dumps({spelling: None}), encoding="utf-8") + load_config(tmp_config) + empty = tmp_config.read_bytes() + assert runner.invoke(app, ["skill", "unblock", "absent"]).exit_code == 0 + assert tmp_config.read_bytes() == empty + result = runner.invoke(app, ["skill", "block", "tag-memory"]) + assert result.exit_code == 0, result.output + assert json.loads(tmp_config.read_text(encoding="utf-8")) == {spelling: {"blocklist": ["tag-memory"]}} + assert load_raven_config(config_path=tmp_config).skill_forge.blocklist == ["tag-memory"] + before = tmp_config.read_bytes() + assert runner.invoke(app, ["skill", "block", "TAG-MEMORY"]).exit_code == 0 + assert tmp_config.read_bytes() == before + result = runner.invoke(app, ["skill", "unblock", "TAG-MEMORY"]) + assert result.exit_code == 0 + assert "Unblocked" in result.stdout and "skillForge" not in result.stdout + assert json.loads(tmp_config.read_text(encoding="utf-8")) == {spelling: {"blocklist": []}} + + def test_skill_block_reports_the_block_it_wrote_on_a_snake_config(tmp_config: Path) -> None: """The result line must not name a key this write did not use. diff --git a/tests/test_config_update.py b/tests/test_config_update.py index ddfbd1260..aab8958f2 100644 --- a/tests/test_config_update.py +++ b/tests/test_config_update.py @@ -548,6 +548,16 @@ def test_skill_block_writes_camel_into_empty_config(cfg_path: Path) -> None: assert _read(cfg_path)["skillForge"] == {"blocklist": ["alpha"]} +@pytest.mark.parametrize("spelling", ["skillForge", "skill_forge"]) +@pytest.mark.parametrize("invalid", [[], 1, "invalid"]) +def test_skill_block_preserves_an_invalid_section(cfg_path, spelling, invalid): + cfg_path.write_text(json.dumps({spelling: invalid}), encoding="utf-8") + before = cfg_path.read_bytes() + with pytest.raises(ValueError, match="object or null"): + set_skill_blocked("alpha", True, config_path=cfg_path) + assert cfg_path.read_bytes() == before + + def test_skill_block_respects_existing_snake_casing(cfg_path: Path) -> None: cfg_path.write_text( json.dumps({"skill_forge": {"auto_install": "off"}}),