Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
22 changes: 20 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,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.
Expand All @@ -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"):
Comment thread
mnriem marked this conversation as resolved.
Outdated
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 +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:
Expand Down
55 changes: 55 additions & 0 deletions tests/integrations/test_integration_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down