diff --git a/src/specify_cli/integrations/junie/__init__.py b/src/specify_cli/integrations/junie/__init__.py index 2d4a6b32d9..8056a6ccac 100644 --- a/src/specify_cli/integrations/junie/__init__.py +++ b/src/specify_cli/integrations/junie/__init__.py @@ -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}", + 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. @@ -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( diff --git a/tests/integrations/test_integration_junie.py b/tests/integrations/test_integration_junie.py index a6234ba734..f9a1010cff 100644 --- a/tests/integrations/test_integration_junie.py +++ b/tests/integrations/test_integration_junie.py @@ -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):