Skip to content
Open
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
16 changes: 8 additions & 8 deletions src/specify_cli/integrations/alquimia/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,13 @@ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str
if dash_count == 1 and stripped.startswith(f"{key}:"):
return content

# Inject before the closing --- of frontmatter
# Inject before the closing --- of frontmatter. Always emit a
# newline after the injected key so the key and the closing ---
# stay on separate lines even when the closing delimiter is the
# last line of the file with no trailing newline -- otherwise the
# injected text glues onto the "---" (e.g. "user-invocable: true---"),
# destroying the delimiter so a later call's pre-scan/injection
# never finds a second "---" and silently drops that key entirely.
out: list[str] = []
dash_count = 0
injected = False
Expand All @@ -161,13 +167,7 @@ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str
if stripped == "---":
dash_count += 1
if dash_count == 2 and not injected:
if line.endswith("\r\n"):
eol = "\r\n"
elif line.endswith("\n"):
eol = "\n"
else:
eol = ""
out.append(f"{key}: {value}{eol}")
out.append(f"{key}: {value}\n")
injected = True
out.append(line)
return "".join(out)
Expand Down
16 changes: 8 additions & 8 deletions src/specify_cli/integrations/claude/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,13 @@ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str
if dash_count == 1 and stripped.startswith(f"{key}:"):
return content

# Inject before the closing --- of frontmatter
# Inject before the closing --- of frontmatter. Always emit a
# newline after the injected key so the key and the closing ---
# stay on separate lines even when the closing delimiter is the
# last line of the file with no trailing newline -- otherwise the
# injected text glues onto the "---" (e.g. "user-invocable: true---"),
# destroying the delimiter so a later call's pre-scan/injection
# never finds a second "---" and silently drops that key entirely.
out: list[str] = []
dash_count = 0
injected = False
Expand All @@ -182,13 +188,7 @@ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str
if stripped == "---":
dash_count += 1
if dash_count == 2 and not injected:
if line.endswith("\r\n"):
eol = "\r\n"
elif line.endswith("\n"):
eol = "\n"
else:
eol = ""
out.append(f"{key}: {value}{eol}")
out.append(f"{key}: {value}\n")
injected = True
out.append(line)
return "".join(out)
Expand Down
16 changes: 8 additions & 8 deletions src/specify_cli/integrations/vibe/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,13 @@ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str
if dash_count == 1 and stripped.startswith(f"{key}:"):
return content

# Inject before the closing --- of frontmatter
# Inject before the closing --- of frontmatter. Always emit a
# newline after the injected key so the key and the closing ---
# stay on separate lines even when the closing delimiter is the
# last line of the file with no trailing newline -- otherwise the
# injected text glues onto the "---" (e.g. "user-invocable: true---"),
# destroying the delimiter so a later call's pre-scan/injection
# never finds a second "---" and silently drops that key entirely.
out: list[str] = []
dash_count = 0
injected = False
Expand All @@ -124,13 +130,7 @@ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str
if stripped == "---":
dash_count += 1
if dash_count == 2 and not injected:
if line.endswith("\r\n"):
eol = "\r\n"
elif line.endswith("\n"):
eol = "\n"
else:
eol = ""
out.append(f"{key}: {value}{eol}")
out.append(f"{key}: {value}\n")
injected = True
out.append(line)
return "".join(out)
Expand Down
39 changes: 39 additions & 0 deletions tests/integrations/test_integration_alquimia.py
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,45 @@ def test_skills_default_post_process_preserves_content_without_hooks(
assert agy.post_process_skill_content(content) == content


class TestAlquimiaInjectFrontmatterFlagNoTrailingNewline:
"""`_inject_frontmatter_flag` must not corrupt content whose closing
frontmatter delimiter is the file's last line with no trailing newline.

`post_process_skill_content` calls this helper on content from
"external skill generators (presets, extensions)" (per the base
class's docstring) -- not guaranteed to end with a trailing newline.
Without a newline after the injected line, the injected text glues
onto the closing `---`, destroying the delimiter.
"""

def test_single_call_keeps_delimiter_on_its_own_line(self):
from specify_cli.integrations.alquimia import AlquimiaAIIntegration

content = "---\nname: x\n---"
result = AlquimiaAIIntegration._inject_frontmatter_flag(
content, "user-invocable"
)
assert result == "---\nname: x\nuser-invocable: true\n---"

def test_chained_calls_both_apply(self):
"""The exact sequence `post_process_skill_content` runs: a second
injected key must still land, not be silently dropped because the
first call already destroyed the closing `---` line."""
from specify_cli.integrations.alquimia import AlquimiaAIIntegration

content = "---\nname: x\n---"
result = AlquimiaAIIntegration._inject_frontmatter_flag(
content, "user-invocable"
)
result = AlquimiaAIIntegration._inject_frontmatter_flag(
result, "disable-model-invocation", "false"
)
assert result == (
"---\nname: x\nuser-invocable: true\n"
"disable-model-invocation: false\n---"
)


class TestAlquimiaHookCommandNote:
"""Verify dot-to-hyphen normalization note is injected in hook sections."""

Expand Down
39 changes: 39 additions & 0 deletions tests/integrations/test_integration_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,45 @@ def test_skills_default_post_process_preserves_content_without_hooks(self, tmp_p
assert agy.post_process_skill_content(content) == content


class TestClaudeInjectFrontmatterFlagNoTrailingNewline:
"""`_inject_frontmatter_flag` must not corrupt content whose closing
frontmatter delimiter is the file's last line with no trailing newline.

`post_process_skill_content` calls this helper on content from
"external skill generators (presets, extensions)" (per its own
docstring) -- not guaranteed to end with a trailing newline. Without a
newline after the injected line, the injected text glues onto the
closing `---`, destroying the delimiter.
"""

def test_single_call_keeps_delimiter_on_its_own_line(self):
from specify_cli.integrations.claude import ClaudeIntegration

content = "---\nname: x\n---"
result = ClaudeIntegration._inject_frontmatter_flag(
content, "user-invocable"
)
assert result == "---\nname: x\nuser-invocable: true\n---"

def test_chained_calls_both_apply(self):
"""The exact sequence `post_process_skill_content` runs: a second
injected key must still land, not be silently dropped because the
first call already destroyed the closing `---` line."""
from specify_cli.integrations.claude import ClaudeIntegration

content = "---\nname: x\n---"
result = ClaudeIntegration._inject_frontmatter_flag(
content, "user-invocable"
)
result = ClaudeIntegration._inject_frontmatter_flag(
result, "disable-model-invocation", "false"
)
assert result == (
"---\nname: x\nuser-invocable: true\n"
"disable-model-invocation: false\n---"
)


class TestClaudeForkContext:
"""Verify context: fork is injected only for commands listed in FORK_CONTEXT_COMMANDS."""

Expand Down
39 changes: 39 additions & 0 deletions tests/integrations/test_integration_vibe.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,3 +334,42 @@ def test_all_skills_have_disable_model_invocation(self, tmp_path):
assert parsed.get("disable-model-invocation") is False, (
f"{f.parent.name}/SKILL.md is missing disable-model-invocation: false in frontmatter"
)


class TestVibeInjectFrontmatterFlagNoTrailingNewline:
"""`_inject_frontmatter_flag` must not corrupt content whose closing
frontmatter delimiter is the file's last line with no trailing newline.

`post_process_skill_content` calls this helper on content from
"external skill generators (presets, extensions)" (per the base
class's docstring) -- not guaranteed to end with a trailing newline.
Without a newline after the injected line, the injected text glues
onto the closing `---`, destroying the delimiter.
"""

def test_single_call_keeps_delimiter_on_its_own_line(self):
from specify_cli.integrations.vibe import VibeIntegration

content = "---\nname: x\n---"
result = VibeIntegration._inject_frontmatter_flag(
content, "user-invocable"
)
assert result == "---\nname: x\nuser-invocable: true\n---"

def test_chained_calls_both_apply(self):
"""The exact sequence `post_process_skill_content` runs: a second
injected key must still land, not be silently dropped because the
first call already destroyed the closing `---` line."""
from specify_cli.integrations.vibe import VibeIntegration

content = "---\nname: x\n---"
result = VibeIntegration._inject_frontmatter_flag(
content, "user-invocable"
)
result = VibeIntegration._inject_frontmatter_flag(
result, "disable-model-invocation", "false"
)
assert result == (
"---\nname: x\nuser-invocable: true\n"
"disable-model-invocation: false\n---"
)