Skip to content

Commit bd595cf

Browse files
authored
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.
1 parent 85d3ed2 commit bd595cf

2 files changed

Lines changed: 113 additions & 2 deletions

File tree

src/specify_cli/integrations/claude/__init__.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,16 @@ class ClaudeIntegration(SkillsIntegration):
6767

6868
@staticmethod
6969
def inject_argument_hint(content: str, hint: str) -> str:
70-
"""Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.
70+
"""Insert ``argument-hint`` after the ``description:`` scalar in YAML frontmatter.
71+
72+
A long ``description`` gets folded by the YAML dumper across
73+
indented continuation lines (plain or quoted), and an embedded
74+
paragraph break can add unindented blank lines inside a quoted
75+
scalar. Inserting the new line right after the *first* line of
76+
that scalar — instead of after the whole scalar — either produces
77+
invalid YAML or gets silently absorbed into the description
78+
string (#4044), so every continuation line (indented, or blank)
79+
is skipped first.
7180
7281
Skips injection if ``argument-hint:`` already exists in the
7382
frontmatter to avoid duplicate keys.
@@ -90,15 +99,29 @@ def inject_argument_hint(content: str, hint: str) -> str:
9099
in_fm = False
91100
dash_count = 0
92101
injected = False
93-
for line in lines:
102+
i = 0
103+
n = len(lines)
104+
while i < n:
105+
line = lines[i]
94106
stripped = line.rstrip("\n\r")
95107
if stripped == "---":
96108
dash_count += 1
97109
in_fm = dash_count == 1
98110
out.append(line)
111+
i += 1
99112
continue
100113
if in_fm and not injected and stripped.startswith("description:"):
101114
out.append(line)
115+
i += 1
116+
# Skip past folded/quoted continuation lines of the scalar
117+
# before inserting, so the new key lands after it ends.
118+
# Blank lines count too: PyYAML emits unindented blank
119+
# lines for embedded "\n\n" inside a quoted scalar.
120+
while i < n and (
121+
lines[i][:1] in (" ", "\t") or lines[i].rstrip("\r\n") == ""
122+
):
123+
out.append(lines[i])
124+
i += 1
102125
# Preserve the exact line-ending style (\r\n vs \n)
103126
if line.endswith("\r\n"):
104127
eol = "\r\n"
@@ -111,6 +134,7 @@ def inject_argument_hint(content: str, hint: str) -> str:
111134
injected = True
112135
continue
113136
out.append(line)
137+
i += 1
114138
return "".join(out)
115139

116140
def _render_skill(self, template_name: str, frontmatter: dict[str, Any], body: str) -> str:

tests/integrations/test_integration_claude.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,93 @@ def test_inject_argument_hint_skips_if_already_present(self):
451451
hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:"))
452452
assert hint_count == 1
453453

454+
def test_inject_argument_hint_survives_folded_description(self):
455+
"""A long description folded across lines must not corrupt the YAML (#4044).
456+
457+
A description long enough for the YAML dumper to fold it into a
458+
multi-line plain scalar previously had ``argument-hint:`` spliced
459+
into the *middle* of that scalar, producing invalid YAML.
460+
"""
461+
from specify_cli.integrations.claude import ClaudeIntegration
462+
463+
frontmatter = {
464+
"name": "speckit-specify",
465+
"description": (
466+
"Create or update the feature specification from a natural "
467+
"language feature description. Also accepts an issue URL "
468+
"resolved via gh CLI (demo customization)."
469+
),
470+
"compatibility": "Requires spec-kit project structure with .specify/ directory",
471+
}
472+
frontmatter_text = yaml.safe_dump(
473+
frontmatter, sort_keys=False, allow_unicode=True
474+
).strip()
475+
content = f"---\n{frontmatter_text}\n---\n\nBody text\n"
476+
assert "\n " in content, "fixture description must actually fold across lines"
477+
478+
result = ClaudeIntegration.inject_argument_hint(content, "Describe the feature")
479+
480+
parsed = yaml.safe_load(result.split("---")[1])
481+
assert parsed["argument-hint"] == "Describe the feature"
482+
assert parsed["description"] == frontmatter["description"]
483+
484+
def test_inject_argument_hint_survives_quoted_folded_description(self):
485+
"""A folded description forced into quotes must not absorb the hint (#4044)."""
486+
from specify_cli.integrations.claude import ClaudeIntegration
487+
488+
frontmatter = {
489+
"name": "speckit-specify",
490+
"description": (
491+
"Create or update the feature specification from a natural "
492+
"language feature description. Also accepts a GitHub "
493+
"issue/PR URL or #N reference resolved via gh CLI (demo)."
494+
),
495+
"compatibility": "Requires spec-kit project structure with .specify/ directory",
496+
}
497+
frontmatter_text = yaml.safe_dump(
498+
frontmatter, sort_keys=False, allow_unicode=True
499+
).strip()
500+
content = f"---\n{frontmatter_text}\n---\n\nBody text\n"
501+
assert "\n " in content, "fixture description must actually fold across lines"
502+
503+
result = ClaudeIntegration.inject_argument_hint(content, "Describe the feature")
504+
505+
parsed = yaml.safe_load(result.split("---")[1])
506+
assert parsed["argument-hint"] == "Describe the feature"
507+
assert parsed["description"] == frontmatter["description"]
508+
509+
def test_inject_argument_hint_survives_multi_paragraph_description(self):
510+
"""A description with an embedded blank line must not absorb the hint.
511+
512+
PyYAML serializes an embedded ``\\n\\n`` inside a quoted scalar as
513+
unindented blank lines, not indented ones, so a fix that only skips
514+
indented continuation lines still fails on this case.
515+
"""
516+
from specify_cli.integrations.claude import ClaudeIntegration
517+
518+
frontmatter = {
519+
"name": "speckit-specify",
520+
"description": (
521+
"First paragraph of a fairly long description that will "
522+
"need to wrap across multiple lines when dumped by PyYAML."
523+
"\n\n"
524+
"Second paragraph continues the description after a blank "
525+
"line separator to force embedded newlines in the scalar."
526+
),
527+
"compatibility": "Requires spec-kit project structure with .specify/ directory",
528+
}
529+
frontmatter_text = yaml.safe_dump(
530+
frontmatter, sort_keys=False, allow_unicode=True
531+
).strip()
532+
content = f"---\n{frontmatter_text}\n---\n\nBody text\n"
533+
assert "\n\n" in frontmatter_text, "fixture must produce a blank continuation line"
534+
535+
result = ClaudeIntegration.inject_argument_hint(content, "Describe the feature")
536+
537+
parsed = yaml.safe_load(result.split("---")[1])
538+
assert parsed["argument-hint"] == "Describe the feature"
539+
assert parsed["description"] == frontmatter["description"]
540+
454541

455542
class TestClaudeDisableModelInvocation:
456543
"""Verify disable-model-invocation is false for Claude skills."""

0 commit comments

Comments
 (0)