Integrate Junie with dot-to-hyphen behavior and command formatting (#4073)

* Add Junie integration with dot-to-hyphen behavior, command formatting, and file transformations. Based on Cline Integration.

* Fix references to Cline in Junie integration and update class/test names for consistency.

* Fix references to Cline in Junie integration and update class/test names for consistency.

* Modified to generate correct formatting in junie
This commit is contained in:
Luca Botti
2026-08-12 11:36:12 -05:00
committed by GitHub
parent 996b24c561
commit f2583e675c
3 changed files with 381 additions and 0 deletions
+5
View File
@@ -4712,6 +4712,7 @@ class HookExecutor:
kimi_skill_mode = selected_ai == "kimi"
cline_mode = selected_ai == "cline"
forge_mode = selected_ai == "forge"
junie_mode = selected_ai == "junie"
skill_name = self._skill_name_from_command(command_id)
if dollar_skill_mode and skill_name:
@@ -4726,6 +4727,10 @@ class HookExecutor:
from ..integrations.forge import format_forge_command_name
return f"/{format_forge_command_name(command_id)}"
if junie_mode:
from ..integrations.junie import format_junie_command_name
return f"/{format_junie_command_name(command_id)}"
use_slash = is_slash_skills_agent(selected_ai, ai_skills_enabled)
@@ -1,6 +1,51 @@
"""Junie integration (JetBrains)."""
from ..base import MarkdownIntegration
from ..manifest import IntegrationManifest
import re
from pathlib import Path
from typing import Any
# Note injected into hook sections so Junie maps dot-notation command
# names (from extensions.yml) to the hyphenated slash commands it uses.
_HOOK_COMMAND_NOTE = (
"- When constructing slash commands from hook command names, "
"replace dots (`.`) with hyphens (`-`). "
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
)
def format_junie_command_name(cmd_name: str) -> str:
"""Convert command name to Junie-compatible hyphenated format.
Junie does not allow dots inside of slash-commands.
This function converts dot-notation command names to hyphenated format.
The function is idempotent: already-formatted names are returned unchanged.
Examples:
>>> format_junie_command_name("plan")
'speckit-plan'
>>> format_junie_command_name("speckit.plan")
'speckit-plan'
>>> format_junie_command_name("speckit.git.commit")
'speckit-git-commit'
Args:
cmd_name: Command name in dot notation (speckit.foo.bar),
hyphenated format (speckit-foo-bar), or plain name (foo)
Returns:
Hyphenated command name with 'speckit-' prefix
"""
cmd_name = cmd_name.replace(".", "-")
if not cmd_name.startswith("speckit-"):
cmd_name = f"speckit-{cmd_name}"
return cmd_name
class JunieIntegration(MarkdownIntegration):
@@ -17,5 +62,117 @@ class JunieIntegration(MarkdownIntegration):
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
"inject_name": True,
"format_name": format_junie_command_name,
"invoke_separator": "-",
}
multi_install_safe = True
invoke_separator = "-"
def command_filename(self, template_name: str) -> str:
return format_junie_command_name(template_name) + ".md"
def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""Junie installs hyphenated slash-commands (``/speckit-<name>``), so the
dispatch invocation must match. The inherited MarkdownIntegration default
builds the dotted ``/speckit.<name>``, which references a command Junie
never registered. Reuse the same hyphenation as command_filename /
the injected frontmatter name (see ``format_junie_command_name``),
mirroring the forge integration.
"""
invocation = "/" + format_junie_command_name(command_name)
if args:
invocation = f"{invocation} {args}"
return invocation
def process_template(self, *args, **kwargs):
"""Ensure shared templates render Junie command references with hyphens."""
kwargs.setdefault("invoke_separator", self.invoke_separator)
return super().process_template(*args, **kwargs)
@staticmethod
def _inject_hook_command_note(content: str) -> str:
"""Insert a dot-to-hyphen note before each hook output instruction.
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.
"""
if "replace dots" in content:
return content
def repl(m: re.Match[str]) -> str:
indent = m.group(1)
instruction = m.group(2)
# ``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")
+ eol
+ indent
+ instruction
+ eol
)
return re.sub(
r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
repl,
content,
)
@staticmethod
def _rewrite_handoff_references(content: str) -> str:
"""Replace dot-notation agent references in handoffs with hyphens."""
return re.sub(
r"(?m)^(\s*agent:\s*)(speckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*)",
lambda m: f"{m.group(1)}{format_junie_command_name(m.group(2))}",
content,
)
def post_process_command_content(self, content: str) -> str:
"""Apply Junie-specific transformations to command content.
Overrides the ``IntegrationBase`` hook of the same name so that
``CommandRegistrar.register_commands()`` (which dispatches to
``post_process_command_content``) applies these transforms to
extension/preset command files too, not just core commands.
"""
updated = self._inject_hook_command_note(content)
updated = self._rewrite_handoff_references(updated)
return updated
def setup(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Install Junie commands and apply post-processing transformations."""
created = super().setup(project_root, manifest, parsed_options, **opts)
# Post-process generated command files
dest_dir = self.commands_dest(project_root).resolve()
for path in created:
# Only touch .md files under the commands directory
try:
path.resolve().relative_to(dest_dir)
except ValueError:
continue
if path.suffix != ".md":
continue
content_bytes = path.read_bytes()
content = content_bytes.decode("utf-8")
updated = self.post_process_command_content(content)
if updated != content:
path.write_bytes(updated.encode("utf-8"))
self.record_file_in_manifest(path, project_root, manifest)
return created