diff --git a/src/specify_cli/integrations/alquimia/__init__.py b/src/specify_cli/integrations/alquimia/__init__.py index 132615206d..003bffa889 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. 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 @@ -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) diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 2ce7fb6dcc..a056634f1e 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. 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 @@ -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) diff --git a/src/specify_cli/integrations/vibe/__init__.py b/src/specify_cli/integrations/vibe/__init__.py index 4412239301..678f7693cc 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. 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 @@ -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) diff --git a/tests/integrations/test_integration_alquimia.py b/tests/integrations/test_integration_alquimia.py index e8eab8281c..c03ce5a14a 100644 --- a/tests/integrations/test_integration_alquimia.py +++ b/tests/integrations/test_integration_alquimia.py @@ -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.""" diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 3718af9740..32dbc392c0 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -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.""" diff --git a/tests/integrations/test_integration_vibe.py b/tests/integrations/test_integration_vibe.py index 55f410c088..e2e461a9a1 100644 --- a/tests/integrations/test_integration_vibe.py +++ b/tests/integrations/test_integration_vibe.py @@ -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---" + )