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
26 changes: 21 additions & 5 deletions src/specify_cli/integrations/cline/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,30 +101,46 @@ def _inject_hook_command_note(content: str) -> str:

Targets the line ``- For each executable hook, output the following``
and inserts the note on the line before it, matching its indentation.
Skips if the note is already present.
Skips individual instructions that already have the note immediately
above them.
"""
if "replace dots" in content:
return content
note = _HOOK_COMMAND_NOTE.rstrip("\n")

def repl(m: re.Match[str]) -> str:
indent = m.group(1)
instruction = m.group(2)
# Check the line immediately above this instruction, mirroring the
# shared ``SkillsIntegration`` helper. The previous whole-document
# ``if "replace dots" in content`` scan meant any command or
# extension markdown whose prose merely contained that phrase lost
# the note entirely -- leaving the agent told to emit a dotted
# ``/speckit.git.commit``, which Cline never registers -- and a
# document with one already-noted section never got a note on a
# second, un-noted one.
previous_lines = content[:m.start()].splitlines()
if previous_lines and previous_lines[-1] == indent + note:
return m.group(0)
# ``eol`` is empty when the regex matched via ``$`` because the
# instruction was the final line of a file with no trailing
# newline. Default to ``\n`` so the note never collapses onto
# the same line as the instruction.
eol = m.group(3) or "\n"
return (
indent
+ _HOOK_COMMAND_NOTE.rstrip("\n")
+ note
+ eol
+ indent
+ instruction
+ eol
)

# ``[ \t]*`` rather than ``\s*``: ``\s`` matches newlines, so the
# captured "indent" could swallow a preceding blank line and the note
# was then emitted with a spurious blank line between it and the
# instruction. This also matches the shared base helper, without which
# the per-instruction check above cannot line up.
return re.sub(
r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
r"(?m)^([ \t]*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
repl,
content,
)
Expand Down
53 changes: 53 additions & 0 deletions tests/integrations/test_integration_cline.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,59 @@ def test_cline_hook_instruction_injection_no_trailing_newline(self):
# Instruction stays on its own line rather than being mashed onto the note.
assert "\n- For each executable hook, output the following:" in injected

def test_cline_hook_note_not_suppressed_by_unrelated_prose(self):
"""Unrelated prose must not suppress the note for a real instruction.

The idempotency guard used to be a whole-document substring scan
(``if "replace dots" in content``), so any command or extension
markdown whose prose merely contained that phrase lost the note
entirely -- leaving the agent told to emit ``/speckit.git.commit``,
a dotted command Cline never registers.
"""
cline = get_integration("cline")
content = (
"# DB extension command\n\n"
"When normalizing table names, replace dots with underscores.\n\n"
"## Pre-Execution Hooks\n"
"- For each executable hook, output the following:\n"
)
injected = cline._inject_hook_command_note(content)
assert "`/speckit-git-commit`" in injected, injected
# The user's own prose is untouched.
assert "replace dots with underscores" in injected

def test_cline_hook_note_added_to_every_un_noted_instruction(self):
"""A second, un-noted hook section must still get its own note."""
cline = get_integration("cline")
instruction = "- For each executable hook, output the following:\n"
# Section 1 already carries the note; section 2 does not.
first = cline._inject_hook_command_note("## Hooks A\n" + instruction)
content = first + "\n## Hooks B\n" + instruction

injected = cline._inject_hook_command_note(content)
assert injected.count("replace dots (`.`) with hyphens (`-`)") == 2, injected
# Still idempotent: re-running adds nothing.
assert cline._inject_hook_command_note(injected) == injected

def test_cline_hook_note_sits_directly_above_indented_instruction(self):
"""No blank line may be inserted between the note and the instruction.

The regex captured indentation with ``\\s*``, which matches newlines,
so a preceding blank line was swallowed into the "indent" and re-emitted
between the note and the instruction.
"""
cline = get_integration("cline")
content = "## Hooks\n\n - For each executable hook, output the following:\n"
injected = cline._inject_hook_command_note(content)
lines = injected.splitlines()
instruction_idx = next(
i for i, line in enumerate(lines) if "For each executable hook" in line
)
assert "replace dots" in lines[instruction_idx - 1], injected
# Indentation is preserved on both lines.
assert lines[instruction_idx].startswith(" - For each")
assert lines[instruction_idx - 1].startswith(" - When constructing")

# -- Overrides for MarkdownIntegrationTests ---------------------------

def test_setup_creates_files(self, tmp_path):
Expand Down