From 2ef96532d22d39978081c089acb68b611ee6b8aa Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:07:29 +0500 Subject: [PATCH] fix(agents): coerce a non-string description in TOML command rendering (#3799) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CommandRegistrar.render_toml_command passes the raw frontmatter `description` straight into `_render_basic_toml_string`, which iterates the value and calls ord() on each character. Frontmatter comes from yaml.safe_load, so description can be any YAML type: description='ok string' -> description = "ok string" description=None -> TypeError: 'NoneType' object is not iterable description=42 -> TypeError: 'int' object is not iterable description=True -> TypeError: 'bool' object is not iterable description=['a','b'] -> description = "ab" <- silently WRONG value This is a format-branch asymmetry: it is the only renderer reached from register_commands' format branches that does not normalise description. render_yaml_command (same class, ~70 lines below) already does exactly `if not isinstance(description, str): description = str(description) if description is not None else ""`, render_markdown_command goes through yaml.dump which handles any type, and TomlIntegration._extract_description returns "" for a non-str. So only extension/preset commands rendered for the two TOML agents were affected. Apply the same coercion the sibling uses. After: None -> "", 42 -> "42", True -> "True", ['a','b'] -> "['a', 'b']", each still valid parseable TOML. String descriptions are untouched. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) --- src/specify_cli/agents.py | 14 +++++++++++++- tests/test_extensions.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index a7f40a7ef..b2861d0ad 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -302,8 +302,20 @@ class CommandRegistrar: toml_lines = [] if "description" in frontmatter: + # Frontmatter comes from ``yaml.safe_load``, so ``description`` can + # be any YAML type: ``description:`` with no value yields None, + # ``description: 2`` an int, an unquoted ``true`` a bool. + # ``_render_basic_toml_string`` iterates the value and calls ord() + # on each character, so a non-string raises a raw TypeError -- and a + # list of single-character items is silently concatenated into a + # wrong value (``["a", "b"]`` -> ``"ab"``). Coerce first, matching + # ``render_yaml_command`` below and ``TomlIntegration + # ._extract_description``, which both normalise it already. + description = frontmatter["description"] + if not isinstance(description, str): + description = str(description) if description is not None else "" toml_lines.append( - f"description = {self._render_basic_toml_string(frontmatter['description'])}" + f"description = {self._render_basic_toml_string(description)}" ) toml_lines.append("") diff --git a/tests/test_extensions.py b/tests/test_extensions.py index ad0ad9b31..4f33a9406 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -2720,6 +2720,36 @@ Real body starts here. assert parsed["description"] == "first line\nsecond line\n" + @pytest.mark.parametrize( + ("description", "expected"), + [ + (None, ""), # "description:" with no value + (42, "42"), # unquoted number + (True, "True"), # unquoted boolean + (["a", "b"], "['a', 'b']"), # was silently concatenated to "ab" + ], + ) + def test_render_toml_command_coerces_non_string_description( + self, description, expected + ): + """Frontmatter comes from yaml.safe_load, so description can be any type. + + _render_basic_toml_string iterates the value and calls ord() per + character, so a non-string raised a raw TypeError and a list of + single-character items was silently concatenated into a wrong value. + render_yaml_command (same class) already coerces; this brings the TOML + branch to parity. + """ + from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar + + registrar = AgentCommandRegistrar() + output = registrar.render_toml_command( + {"description": description}, "body", "extension:test-ext" + ) + + parsed = tomllib.loads(output) + assert parsed["description"] == expected + def test_render_toml_command_escapes_control_characters(self): """Control characters and a lone CR must be escaped so the TOML parses.