From d748a86a5606295c250314dcb1e97c1507708341 Mon Sep 17 00:00:00 2001 From: chelsealong Date: Tue, 11 Aug 2026 03:59:39 +0000 Subject: [PATCH 1/2] fix(claude): make argument-hint injection fold-aware for long descriptions ClaudeIntegration.inject_argument_hint spliced argument-hint: "..." as a raw text line right after the first line starting with "description:". When a description is long enough for the YAML dumper to fold it across indented continuation lines, that splice landed inside the scalar, producing invalid YAML (plain scalar) or silently absorbing the hint into the description string (quoted scalar). This reproduces #3991 for the case #3996 didn't cover: bundled core commands have no argument-hint in their source frontmatter, so the structural apply_argument_hint path is a no-op and this raw-text fallback is what actually runs. Skip every continuation line of the description scalar (anything more indented than the key itself) before inserting, so the new key always lands after the whole scalar ends rather than in the middle of it. Fixes #4044 --- .../integrations/claude/__init__.py | 22 +++++++- tests/integrations/test_integration_claude.py | 55 +++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 39732794af..18e2ebfa87 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -67,7 +67,14 @@ class ClaudeIntegration(SkillsIntegration): @staticmethod def inject_argument_hint(content: str, hint: str) -> str: - """Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter. + """Insert ``argument-hint`` after the ``description:`` scalar in YAML frontmatter. + + A long ``description`` gets folded by the YAML dumper across + indented continuation lines (plain or quoted). Inserting the new + line right after the *first* line of that scalar — instead of after + the whole scalar — either produces invalid YAML or gets silently + absorbed into the description string (#4044), so every continuation + line (anything more indented than the key itself) is skipped first. Skips injection if ``argument-hint:`` already exists in the frontmatter to avoid duplicate keys. @@ -90,15 +97,25 @@ def inject_argument_hint(content: str, hint: str) -> str: in_fm = False dash_count = 0 injected = False - for line in lines: + i = 0 + n = len(lines) + while i < n: + line = lines[i] stripped = line.rstrip("\n\r") if stripped == "---": dash_count += 1 in_fm = dash_count == 1 out.append(line) + i += 1 continue if in_fm and not injected and stripped.startswith("description:"): out.append(line) + i += 1 + # Skip past folded/quoted continuation lines of the scalar + # before inserting, so the new key lands after it ends. + while i < n and lines[i][:1] in (" ", "\t"): + out.append(lines[i]) + i += 1 # Preserve the exact line-ending style (\r\n vs \n) if line.endswith("\r\n"): eol = "\r\n" @@ -111,6 +128,7 @@ def inject_argument_hint(content: str, hint: str) -> str: injected = True continue out.append(line) + i += 1 return "".join(out) def _render_skill(self, template_name: str, frontmatter: dict[str, Any], body: str) -> str: diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 7916fdeba9..9db186d26c 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -451,6 +451,61 @@ def test_inject_argument_hint_skips_if_already_present(self): hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:")) assert hint_count == 1 + def test_inject_argument_hint_survives_folded_description(self): + """A long description folded across lines must not corrupt the YAML (#4044). + + A description long enough for the YAML dumper to fold it into a + multi-line plain scalar previously had ``argument-hint:`` spliced + into the *middle* of that scalar, producing invalid YAML. + """ + from specify_cli.integrations.claude import ClaudeIntegration + + frontmatter = { + "name": "speckit-specify", + "description": ( + "Create or update the feature specification from a natural " + "language feature description. Also accepts an issue URL " + "resolved via gh CLI (demo customization)." + ), + "compatibility": "Requires spec-kit project structure with .specify/ directory", + } + frontmatter_text = yaml.safe_dump( + frontmatter, sort_keys=False, allow_unicode=True + ).strip() + content = f"---\n{frontmatter_text}\n---\n\nBody text\n" + assert "\n " in content, "fixture description must actually fold across lines" + + result = ClaudeIntegration.inject_argument_hint(content, "Describe the feature") + + parsed = yaml.safe_load(result.split("---")[1]) + assert parsed["argument-hint"] == "Describe the feature" + assert parsed["description"] == frontmatter["description"] + + def test_inject_argument_hint_survives_quoted_folded_description(self): + """A folded description forced into quotes must not absorb the hint (#4044).""" + from specify_cli.integrations.claude import ClaudeIntegration + + frontmatter = { + "name": "speckit-specify", + "description": ( + "Create or update the feature specification from a natural " + "language feature description. Also accepts a GitHub " + "issue/PR URL or #N reference resolved via gh CLI (demo)." + ), + "compatibility": "Requires spec-kit project structure with .specify/ directory", + } + frontmatter_text = yaml.safe_dump( + frontmatter, sort_keys=False, allow_unicode=True + ).strip() + content = f"---\n{frontmatter_text}\n---\n\nBody text\n" + assert "\n " in content, "fixture description must actually fold across lines" + + result = ClaudeIntegration.inject_argument_hint(content, "Describe the feature") + + parsed = yaml.safe_load(result.split("---")[1]) + assert parsed["argument-hint"] == "Describe the feature" + assert parsed["description"] == frontmatter["description"] + class TestClaudeDisableModelInvocation: """Verify disable-model-invocation is false for Claude skills.""" From 1f19ab01fe9a321ba4270269995bc7fe406f6d76 Mon Sep 17 00:00:00 2001 From: chelsealong Date: Tue, 11 Aug 2026 13:01:25 +0000 Subject: [PATCH 2/2] fix(claude): also skip unindented blank lines in description scalar PyYAML serializes an embedded paragraph break ("\n\n") inside a quoted description as unindented blank lines, not indented continuation lines. inject_argument_hint only skipped indented lines, so it still inserted argument-hint mid-scalar for multi-paragraph descriptions, reproducing the #4044 failure modes. Skip blank lines too, and add a regression test for the multi-paragraph case. --- .../integrations/claude/__init__.py | 18 +++++++---- tests/integrations/test_integration_claude.py | 32 +++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 18e2ebfa87..2ce7fb6dcc 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -70,11 +70,13 @@ def inject_argument_hint(content: str, hint: str) -> str: """Insert ``argument-hint`` after the ``description:`` scalar in YAML frontmatter. A long ``description`` gets folded by the YAML dumper across - indented continuation lines (plain or quoted). Inserting the new - line right after the *first* line of that scalar — instead of after - the whole scalar — either produces invalid YAML or gets silently - absorbed into the description string (#4044), so every continuation - line (anything more indented than the key itself) is skipped first. + indented continuation lines (plain or quoted), and an embedded + paragraph break can add unindented blank lines inside a quoted + scalar. Inserting the new line right after the *first* line of + that scalar — instead of after the whole scalar — either produces + invalid YAML or gets silently absorbed into the description + string (#4044), so every continuation line (indented, or blank) + is skipped first. Skips injection if ``argument-hint:`` already exists in the frontmatter to avoid duplicate keys. @@ -113,7 +115,11 @@ def inject_argument_hint(content: str, hint: str) -> str: i += 1 # Skip past folded/quoted continuation lines of the scalar # before inserting, so the new key lands after it ends. - while i < n and lines[i][:1] in (" ", "\t"): + # Blank lines count too: PyYAML emits unindented blank + # lines for embedded "\n\n" inside a quoted scalar. + while i < n and ( + lines[i][:1] in (" ", "\t") or lines[i].rstrip("\r\n") == "" + ): out.append(lines[i]) i += 1 # Preserve the exact line-ending style (\r\n vs \n) diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 9db186d26c..3718af9740 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -506,6 +506,38 @@ def test_inject_argument_hint_survives_quoted_folded_description(self): assert parsed["argument-hint"] == "Describe the feature" assert parsed["description"] == frontmatter["description"] + def test_inject_argument_hint_survives_multi_paragraph_description(self): + """A description with an embedded blank line must not absorb the hint. + + PyYAML serializes an embedded ``\\n\\n`` inside a quoted scalar as + unindented blank lines, not indented ones, so a fix that only skips + indented continuation lines still fails on this case. + """ + from specify_cli.integrations.claude import ClaudeIntegration + + frontmatter = { + "name": "speckit-specify", + "description": ( + "First paragraph of a fairly long description that will " + "need to wrap across multiple lines when dumped by PyYAML." + "\n\n" + "Second paragraph continues the description after a blank " + "line separator to force embedded newlines in the scalar." + ), + "compatibility": "Requires spec-kit project structure with .specify/ directory", + } + frontmatter_text = yaml.safe_dump( + frontmatter, sort_keys=False, allow_unicode=True + ).strip() + content = f"---\n{frontmatter_text}\n---\n\nBody text\n" + assert "\n\n" in frontmatter_text, "fixture must produce a blank continuation line" + + result = ClaudeIntegration.inject_argument_hint(content, "Describe the feature") + + parsed = yaml.safe_load(result.split("---")[1]) + assert parsed["argument-hint"] == "Describe the feature" + assert parsed["description"] == frontmatter["description"] + class TestClaudeDisableModelInvocation: """Verify disable-model-invocation is false for Claude skills."""