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
66 changes: 66 additions & 0 deletions src/specify_cli/integrations/junie/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,66 @@ def _rewrite_handoff_references(content: str) -> str:
lambda m: f"{m.group(1)}{format_junie_command_name(m.group(2))}",
content,
)

@staticmethod
def _inject_allow_prompt_argument(content: str, allow_prompt: bool = True) -> str:
"""Inject allowPromptArgument: true/false into the YAML frontmatter.

If frontmatter exists, it ensures the key is set to the desired value (overwriting if needed).
If not, it creates a minimal frontmatter.
"""
value = "true" if allow_prompt else "false"
if not content.startswith("---"):
# No frontmatter at all? Create one.
return f"---\nallowPromptArgument: {value}\n---\n\n" + content

parts = re.split(r"(?m)^---\s*$", content, maxsplit=2)
if len(parts) < 3:
# Malformed frontmatter (e.g. missing closing dashes)?
return content

frontmatter = parts[1]
body = parts[2]

if "allowPromptArgument:" in frontmatter:
# Overwrite existing key
frontmatter = re.sub(
r"(?m)^(allowPromptArgument:\s*).*",
fr"\1{value}",
Comment thread
dch-actaport marked this conversation as resolved.
frontmatter
)
else:
# Append to frontmatter. Ensure it ends with newline.
# Check if the last line of frontmatter is a key-value pair.
lines = frontmatter.splitlines()
if lines and not lines[-1].strip():
# Remove trailing empty lines in frontmatter
while lines and not lines[-1].strip():
lines.pop()
frontmatter = "\n".join(lines) + "\n"
elif not frontmatter.endswith("\n"):
frontmatter += "\n"

frontmatter += f"allowPromptArgument: {value}\n"

return f"---{frontmatter}---{body}"

@staticmethod
def _transform_body_variables(content: str) -> str:
"""Transform $ARGUMENTS to $prompt and escape other $word by doubling $."""
# 1. $ARGUMENTS -> $prompt
# We do this before regex so we can exclude $prompt from doubling.
content = content.replace("$ARGUMENTS", "$prompt")

# 2. Double $ for other variables: $[A-Za-z_][A-Za-z0-9_-]*
def double_dollar(match: re.Match[str]) -> str:
word = match.group(1)
if word == "prompt":
return "$prompt"
return "$$" + word

return re.sub(r"\$([A-Za-z_][A-Za-z0-9_-]*)", double_dollar, content)

def post_process_command_content(self, content: str) -> str:
"""Apply Junie-specific transformations to command content.

Expand All @@ -140,8 +200,14 @@ def post_process_command_content(self, content: str) -> str:
``post_process_command_content``) applies these transforms to
extension/preset command files too, not just core commands.
"""
# FR-001: Detect $ARGUMENTS before transformation
has_arguments = "$ARGUMENTS" in content

updated = self._inject_hook_command_note(content)
updated = self._rewrite_handoff_references(updated)
# FR-002, FR-003: Set allowPromptArgument based on $ARGUMENTS presence
updated = self._inject_allow_prompt_argument(updated, allow_prompt=has_arguments)
updated = self._transform_body_variables(updated)
return updated

def setup(
Expand Down
81 changes: 81 additions & 0 deletions tests/integrations/test_integration_junie.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,87 @@ def test_junie_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_junie_transformations(self):
"""Verify junie-specific transformations for frontmatter and body."""
junie = get_integration("junie")
content = "---\ndescription: Test\n---\n$ARGUMENTS\n$speckit-plan\n"
updated = junie.post_process_command_content(content)
assert "allowPromptArgument: true" in updated
assert "$prompt" in updated
assert "$ARGUMENTS" not in updated
assert "$$speckit-plan" in updated

def test_junie_frontmatter_uniqueness(self):
"""Verify allowPromptArgument is unique and overwritten. (US1)"""
junie = get_integration("junie")
# With $ARGUMENTS -> true
content = "---\nallowPromptArgument: false\ndescription: Test\n---\n$ARGUMENTS"
updated = junie.post_process_command_content(content)
assert updated.count("allowPromptArgument:") == 1
assert "allowPromptArgument: true" in updated

# Without $ARGUMENTS -> false
content = "---\nallowPromptArgument: true\ndescription: Test\n---\nBody"
updated = junie.post_process_command_content(content)
assert updated.count("allowPromptArgument:") == 1
assert "allowPromptArgument: false" in updated

def test_junie_variable_escaping_regex(self):
"""Verify generic variable escaping using regex. (US2)"""
junie = get_integration("junie")
content = "Hook: $speckit-git-branch, User: $user_name, Prompt: $prompt"
# Note: $prompt should stay $prompt
updated = junie.post_process_command_content(content)
assert "$$speckit-git-branch" in updated
assert "$$user_name" in updated
assert "$prompt" in updated
assert "$$prompt" not in updated

def test_junie_missing_frontmatter(self):
"""Verify frontmatter is created if missing."""
junie = get_integration("junie")
# With $ARGUMENTS
content = "$ARGUMENTS"
updated = junie.post_process_command_content(content)
assert updated.startswith("---\nallowPromptArgument: true\n---\n\n")
assert "$prompt" in updated

# Without $ARGUMENTS
content = "Body"
updated = junie.post_process_command_content(content)
assert updated.startswith("---\nallowPromptArgument: false\n---\n\n")

def test_junie_malformed_frontmatter(self):
"""Verify malformed frontmatter is handled gracefully (returned as is)."""
junie = get_integration("junie")
content = "---\nMalformed frontmatter (missing closing dashes)\nBody"
updated = junie.post_process_command_content(content)
assert updated == content

def test_junie_frontmatter_non_standalone_delimiter(self):
"""Verify --- not on standalone line is NOT identified as delimiter."""
junie = get_integration("junie")
content = "---\ntitle: Foo\n--- not a delimiter\nbody"
updated = junie.post_process_command_content(content)
assert updated == content

def test_junie_frontmatter_with_horizontal_rule_in_body(self):
"""Verify valid frontmatter is preserved and horizontal rule in body is untouched."""
junie = get_integration("junie")
content = "---\ntitle: Foo\n---\nBody with\n---\nhorizontal rule"
updated = junie.post_process_command_content(content)
assert "---" in updated
assert "horizontal rule" in updated
assert updated.count("---") == 3

def test_junie_nested_yaml_protection(self):
"""Verify regex does not match nested allowPromptArgument."""
junie = get_integration("junie")
content = "---\nconfig:\n allowPromptArgument: nested\n---\nBody"
updated = junie.post_process_command_content(content)
assert "allowPromptArgument: nested" in updated
assert "allowPromptArgument: false" not in updated

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

def test_setup_creates_files(self, tmp_path):
Expand Down