Skip to content
Merged
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
5 changes: 5 additions & 0 deletions src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4712,6 +4712,7 @@ def _render_hook_invocation(self, command: Any) -> str:
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:
Expand All @@ -4726,6 +4727,10 @@ def _render_hook_invocation(self, command: Any) -> str:
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)

Expand Down
157 changes: 157 additions & 0 deletions src/specify_cli/integrations/junie/__init__.py
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
lucabotti marked this conversation as resolved.
)


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):
Expand All @@ -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)
Comment thread
lucabotti marked this conversation as resolved.
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
Loading