diff --git a/src/specify_cli/integrations/alquimia/__init__.py b/src/specify_cli/integrations/alquimia/__init__.py index 132615206d..54ea47f486 100644 --- a/src/specify_cli/integrations/alquimia/__init__.py +++ b/src/specify_cli/integrations/alquimia/__init__.py @@ -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. Preserve the + # existing EOL style, but default to "\n" (rather than "") 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 @@ -166,7 +172,7 @@ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str elif line.endswith("\n"): eol = "\n" else: - eol = "" + eol = "\n" out.append(f"{key}: {value}{eol}") injected = True out.append(line) diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 2ce7fb6dcc..9a14cf50b0 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -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. Preserve the + # existing EOL style, but default to "\n" (rather than "") 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 @@ -187,7 +193,7 @@ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str elif line.endswith("\n"): eol = "\n" else: - eol = "" + eol = "\n" out.append(f"{key}: {value}{eol}") injected = True out.append(line) diff --git a/src/specify_cli/integrations/vibe/__init__.py b/src/specify_cli/integrations/vibe/__init__.py index 4412239301..44ae6f96df 100644 --- a/src/specify_cli/integrations/vibe/__init__.py +++ b/src/specify_cli/integrations/vibe/__init__.py @@ -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. Preserve the + # existing EOL style, but default to "\n" (rather than "") 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 @@ -129,7 +135,7 @@ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str elif line.endswith("\n"): eol = "\n" else: - eol = "" + eol = "\n" out.append(f"{key}: {value}{eol}") injected = True out.append(line) diff --git a/tests/integrations/test_integration_alquimia.py b/tests/integrations/test_integration_alquimia.py index e8eab8281c..6e8b6dbadd 100644 --- a/tests/integrations/test_integration_alquimia.py +++ b/tests/integrations/test_integration_alquimia.py @@ -621,6 +621,56 @@ 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---" + ) + + def test_preserves_crlf_line_endings(self): + """When the closing delimiter *does* end with \\r\\n, the injected + line must reuse that EOL rather than switching the file to LF.""" + from specify_cli.integrations.alquimia import AlquimiaAIIntegration + + content = "---\r\nname: x\r\n---\r\n" + result = AlquimiaAIIntegration._inject_frontmatter_flag( + content, "user-invocable" + ) + assert result == "---\r\nname: x\r\nuser-invocable: true\r\n---\r\n" + + class TestAlquimiaHookCommandNote: """Verify dot-to-hyphen normalization note is injected in hook sections.""" diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 3718af9740..8b8067a8a0 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -591,6 +591,56 @@ 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---" + ) + + def test_preserves_crlf_line_endings(self): + """When the closing delimiter *does* end with \\r\\n, the injected + line must reuse that EOL rather than switching the file to LF.""" + from specify_cli.integrations.claude import ClaudeIntegration + + content = "---\r\nname: x\r\n---\r\n" + result = ClaudeIntegration._inject_frontmatter_flag( + content, "user-invocable" + ) + assert result == "---\r\nname: x\r\nuser-invocable: true\r\n---\r\n" + + class TestClaudeForkContext: """Verify context: fork is injected only for commands listed in FORK_CONTEXT_COMMANDS.""" diff --git a/tests/integrations/test_integration_vibe.py b/tests/integrations/test_integration_vibe.py index 55f410c088..8bd26fe75f 100644 --- a/tests/integrations/test_integration_vibe.py +++ b/tests/integrations/test_integration_vibe.py @@ -334,3 +334,53 @@ 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---" + ) + + def test_preserves_crlf_line_endings(self): + """When the closing delimiter *does* end with \\r\\n, the injected + line must reuse that EOL rather than switching the file to LF.""" + from specify_cli.integrations.vibe import VibeIntegration + + content = "---\r\nname: x\r\n---\r\n" + result = VibeIntegration._inject_frontmatter_flag( + content, "user-invocable" + ) + assert result == "---\r\nname: x\r\nuser-invocable: true\r\n---\r\n"