From 97dd737fd9b0baf7db49c637e1b69452f22913b4 Mon Sep 17 00:00:00 2001 From: Luca Botti Date: Wed, 12 Aug 2026 17:34:47 +0200 Subject: [PATCH 1/4] Add Junie integration with dot-to-hyphen behavior, command formatting, and file transformations. Based on Cline Integration. --- .../integrations/junie/__init__.py | 157 +++++++++++++ tests/integrations/test_integration_junie.py | 220 ++++++++++++++++++ 2 files changed, 377 insertions(+) diff --git a/src/specify_cli/integrations/junie/__init__.py b/src/specify_cli/integrations/junie/__init__.py index e1e8a9addb..25d46f37c7 100644 --- a/src/specify_cli/integrations/junie/__init__.py +++ b/src/specify_cli/integrations/junie/__init__.py @@ -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 Cline 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 Cline-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-``), so the + dispatch invocation must match. The inherited MarkdownIntegration default + builds the dotted ``/speckit.``, which references a command Cline + never registered. Reuse the same hyphenation as command_filename / + the injected frontmatter name (see ``format_cline_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 diff --git a/tests/integrations/test_integration_junie.py b/tests/integrations/test_integration_junie.py index 2226e3d544..d80b025185 100644 --- a/tests/integrations/test_integration_junie.py +++ b/tests/integrations/test_integration_junie.py @@ -1,6 +1,47 @@ """Tests for JunieIntegration.""" from .test_integration_base_markdown import MarkdownIntegrationTests +import os +import pytest + +from specify_cli.integrations import get_integration +from specify_cli.integrations.junie import format_junie_command_name +from .test_integration_base_markdown import MarkdownIntegrationTests + +class TestjunieCommandNameFormatter: + """Test the junie command name formatter.""" + + def test_simple_name_without_prefix(self): + """Test formatting a simple name without 'speckit.' prefix.""" + assert format_junie_command_name("plan") == "speckit-plan" + assert format_junie_command_name("tasks") == "speckit-tasks" + assert format_junie_command_name("specify") == "speckit-specify" + + def test_name_with_speckit_prefix(self): + """Test formatting a name that already has 'speckit.' prefix.""" + assert format_junie_command_name("speckit.plan") == "speckit-plan" + assert format_junie_command_name("speckit.tasks") == "speckit-tasks" + + def test_extension_command_name(self): + """Test formatting extension command names with dots.""" + assert ( + format_junie_command_name("speckit.my-extension.example") + == "speckit-my-extension-example" + ) + assert ( + format_junie_command_name("my-extension.example") + == "speckit-my-extension-example" + ) + + def test_idempotent_already_hyphenated(self): + """Test that already-hyphenated names are returned unchanged (idempotent).""" + assert format_junie_command_name("speckit-plan") == "speckit-plan" + assert ( + format_junie_command_name("speckit-my-extension-example") + == "speckit-my-extension-example" + ) + + class TestJunieIntegration(MarkdownIntegrationTests): @@ -8,3 +49,182 @@ class TestJunieIntegration(MarkdownIntegrationTests): FOLDER = ".junie/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".junie/commands" + + @pytest.mark.parametrize( + "cmd_name, expected_filename", + [ + ("plan", "speckit-plan.md"), + ("speckit.plan", "speckit-plan.md"), + ("speckit.git.commit", "speckit-git-commit.md"), + ("speckit", "speckit-speckit.md"), + ("speckitfoo", "speckit-speckitfoo.md"), + ], + ) + + def test_junie_command_filename(self, cmd_name, expected_filename): + """Verify junie uses hyphenated filenames.""" + junie = get_integration("junie") + assert junie.command_filename(cmd_name) == expected_filename + + def test_junie_invoke_separator(self): + """Verify junie uses hyphen as invoke separator.""" + junie = get_integration("junie") + assert junie.invoke_separator == "-" + assert junie.registrar_config["invoke_separator"] == "-" + + def test_junie_name_injection_and_formatting(self): + """Verify junie has inject_name and format_name configured.""" + junie = get_integration("junie") + assert junie.registrar_config["inject_name"] is True + assert junie.registrar_config[ + "format_name"] == format_junie_command_name + + def test_junie_handoff_rewrite(self): + """Verify junie rewrites agent: speckit.foo to agent: speckit-foo.""" + junie = get_integration("junie") + content = "---\nagent: speckit.plan\n---\n" + rewritten = junie._rewrite_handoff_references(content) + assert rewritten == "---\nagent: speckit-plan\n---\n" + + def test_junie_hook_instruction_injection(self): + """Verify junie injects the dot-to-hyphen note for hooks.""" + junie = get_integration("junie") + content = "- For each executable hook, output the following:\n" + injected = junie._inject_hook_command_note(content) + assert "replace dots (`.`) with hyphens (`-`)" in injected + assert "- For each executable hook, output the following:" in injected + + def test_junie_hook_instruction_injection_no_trailing_newline(self): + """Note must not collapse onto the instruction line when the + instruction is the final line with no trailing newline. + + The injection regex matches the end-of-line via ``(\\r\\n|\\n|$)``, so + the captured ``eol`` is empty on a file's last line that lacks a + trailing newline. Without an ``or "\\n"`` fallback the note text and + the instruction are emitted on the same line. + """ + junie = get_integration("junie") + content = "- For each executable hook, output the following:" # no trailing \n + injected = junie._inject_hook_command_note(content) + assert "replace dots (`.`) with hyphens (`-`)" in injected + # Instruction stays on its own line rather than being mashed onto the note. + assert "\n- For each executable hook, output the following:" in injected + + # -- Overrides for MarkdownIntegrationTests --------------------------- + + def test_setup_creates_files(self, tmp_path): + from specify_cli.integrations.manifest import IntegrationManifest + + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + assert len(created) > 0 + cmd_files = [ + f + for f in created + if "scripts" not in f.parts + and f.suffix == ".md" + ] + for f in cmd_files: + assert f.exists() + assert f.name.startswith("speckit-") + assert f.name.endswith(".md") + + specify_file = next( + (f for f in cmd_files if f.name == "speckit-specify.md"), None + ) + assert specify_file is not None + specify_contents = specify_file.read_text(encoding="utf-8") + assert "/speckit-plan" in specify_contents + assert "/speckit.plan" not in specify_contents + + def test_integration_flag_creates_files(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"int-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke( + app, + [ + "init", + "--here", + "--integration", + self.KEY, + "--script", + "sh", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + i = get_integration(self.KEY) + cmd_dir = i.commands_dest(project) + assert cmd_dir.is_dir() + commands = sorted(cmd_dir.glob("speckit-*")) + assert len(commands) > 0 + + def _expected_files(self, script_variant: str) -> list[str]: + """Override to expect hyphenated speckit- prefix.""" + i = get_integration(self.KEY) + cmd_dir = i.registrar_config["dir"] + files = [] + + # Command files + for stem in ( + self.COMMANDS_SUBDIR_STEMS + if hasattr(self, "COMMANDS_SUBDIR_STEMS") + else self.COMMAND_STEMS + ): + files.append(f"{cmd_dir}/speckit-{stem.replace('.', '-')}.md") + + # Framework files + files.append(".specify/integration.json") + files.append(".specify/init-options.json") + files.append(f".specify/integrations/{self.KEY}.manifest.json") + files.append(".specify/integrations/speckit.manifest.json") + files.append(".specify/.gitignore") + + if script_variant == "sh": + for name in [ + "check-prerequisites.sh", + "common.sh", + "create-new-feature.sh", + "resolve-template.sh", + "setup-plan.sh", + "setup-tasks.sh", + ]: + files.append(f".specify/scripts/bash/{name}") + else: + for name in [ + "check-prerequisites.ps1", + "common.ps1", + "create-new-feature.ps1", + "resolve-template.ps1", + "setup-plan.ps1", + "setup-tasks.ps1", + ]: + files.append(f".specify/scripts/powershell/{name}") + + for name in [ + "checklist-template.md", + "constitution-template.md", + "plan-template.md", + "spec-template.md", + "tasks-template.md", + ]: + files.append(f".specify/templates/{name}") + + files.append(".specify/memory/.constitution-template.json") + files.append(".specify/memory/constitution.md") + # Bundled workflow + files.append(".specify/workflows/speckit/workflow.yml") + files.append(".specify/workflows/workflow-registry.json") + + return sorted(files) From 2efcbe5f8133930b4e95e058273fd1956d1e854d Mon Sep 17 00:00:00 2001 From: Luca Botti Date: Wed, 12 Aug 2026 17:45:59 +0200 Subject: [PATCH 2/4] Fix references to Cline in Junie integration and update class/test names for consistency. --- src/specify_cli/integrations/junie/__init__.py | 8 ++++---- tests/integrations/test_integration_junie.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/integrations/junie/__init__.py b/src/specify_cli/integrations/junie/__init__.py index 25d46f37c7..2d4a6b32d9 100644 --- a/src/specify_cli/integrations/junie/__init__.py +++ b/src/specify_cli/integrations/junie/__init__.py @@ -8,7 +8,7 @@ from pathlib import Path from typing import Any -# Note injected into hook sections so Cline maps dot-notation command +# 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, " @@ -18,7 +18,7 @@ def format_junie_command_name(cmd_name: str) -> str: - """Convert command name to Cline-compatible hyphenated format. + """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. @@ -75,9 +75,9 @@ def command_filename(self, template_name: str) -> str: def build_command_invocation(self, command_name: str, args: str = "") -> str: """Junie installs hyphenated slash-commands (``/speckit-``), so the dispatch invocation must match. The inherited MarkdownIntegration default - builds the dotted ``/speckit.``, which references a command Cline + builds the dotted ``/speckit.``, which references a command Junie never registered. Reuse the same hyphenation as command_filename / - the injected frontmatter name (see ``format_cline_command_name``), + the injected frontmatter name (see ``format_junie_command_name``), mirroring the forge integration. """ invocation = "/" + format_junie_command_name(command_name) diff --git a/tests/integrations/test_integration_junie.py b/tests/integrations/test_integration_junie.py index d80b025185..315579f8ab 100644 --- a/tests/integrations/test_integration_junie.py +++ b/tests/integrations/test_integration_junie.py @@ -8,7 +8,7 @@ from specify_cli.integrations.junie import format_junie_command_name from .test_integration_base_markdown import MarkdownIntegrationTests -class TestjunieCommandNameFormatter: +class TestJunieCommandNameFormatter: """Test the junie command name formatter.""" def test_simple_name_without_prefix(self): From c351611f21c9db2f6c7c2657982e092b90ab5cab Mon Sep 17 00:00:00 2001 From: Luca Botti Date: Wed, 12 Aug 2026 17:53:19 +0200 Subject: [PATCH 3/4] Fix references to Cline in Junie integration and update class/test names for consistency. --- tests/integrations/test_integration_junie.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/integrations/test_integration_junie.py b/tests/integrations/test_integration_junie.py index 315579f8ab..a6234ba734 100644 --- a/tests/integrations/test_integration_junie.py +++ b/tests/integrations/test_integration_junie.py @@ -1,6 +1,5 @@ """Tests for JunieIntegration.""" -from .test_integration_base_markdown import MarkdownIntegrationTests import os import pytest From 3ec55b50ff34d9f5f893f75b4cfd06e2d324d2ca Mon Sep 17 00:00:00 2001 From: Luca Botti Date: Wed, 12 Aug 2026 18:02:39 +0200 Subject: [PATCH 4/4] Modified to generate correct formatting in junie --- src/specify_cli/extensions/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 249a5d40fa..fb4a30519d 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -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: @@ -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)