fix(claude): make argument-hint injection fold-aware for long descriptions (#4045)

* 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

* 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.
This commit is contained in:
chelsealong
2026-08-12 02:14:19 +08:00
committed by GitHub
parent 85d3ed289d
commit bd595cf838
2 changed files with 113 additions and 2 deletions
@@ -67,7 +67,16 @@ 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), 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.
@@ -90,15 +99,29 @@ class ClaudeIntegration(SkillsIntegration):
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.
# 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)
if line.endswith("\r\n"):
eol = "\r\n"
@@ -111,6 +134,7 @@ class ClaudeIntegration(SkillsIntegration):
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:
@@ -451,6 +451,93 @@ class TestClaudeArgumentHints:
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"]
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."""