Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions src/specify_cli/integrations/claude/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -90,15 +99,29 @@ 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.
# 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"
Expand All @@ -111,6 +134,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:
Expand Down
87 changes: 87 additions & 0 deletions tests/integrations/test_integration_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,93 @@ 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"]

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."""
Expand Down