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

@staticmethod
def _adapt_argument_placeholders(content: str) -> str:
"""Stop Junie from auto-detecting required ``$identifier`` arguments.

Junie treats every ``$name`` token in a command body as a required
named argument. Map ``$ARGUMENTS`` to Junie's free-text ``$prompt``
(enabled via ``allowPromptArgument`` frontmatter) and drop the prose
example ``$speckit-...`` describing other agents' invocation syntax.
"""
content = content.replace(" or `$speckit-...`", "")
content = content.replace("$ARGUMENTS", "$prompt")
fm = re.match(r"\A---(\r?\n)(.*?)(\r?\n)---(?=\r?\n|\Z)", content, re.S)
if "$prompt" in content and fm:
nl, block = fm.group(1), fm.group(2)
block, n = re.subn(
r"(?m)^allowPromptArgument:[^\r\n]*", "allowPromptArgument: true", block
)
if not n:
block = f"allowPromptArgument: true{nl}{block}"
content = f"---{nl}{block}{content[fm.end(2):]}"
return content

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

Expand All @@ -142,7 +165,7 @@ def post_process_command_content(self, content: str) -> str:
"""
updated = self._inject_hook_command_note(content)
updated = self._rewrite_handoff_references(updated)
return updated
return self._adapt_argument_placeholders(updated)

def setup(
self,
Expand Down
37 changes: 37 additions & 0 deletions tests/integrations/test_integration_junie.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,43 @@ 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_argument_placeholders_adapted(self):
"""$ARGUMENTS / $speckit-... must not become required Junie arguments."""
junie = get_integration("junie")
content = (
"---\ndescription: x\n---\n$ARGUMENTS\n"
"run as `/skill:speckit-...` or `$speckit-...`).\n"
)
out = junie.post_process_command_content(content)
assert out.startswith("---\nallowPromptArgument: true\n")
assert "$prompt" in out
assert "$ARGUMENTS" not in out
assert "$speckit" not in out

def test_junie_allow_prompt_argument_false_is_enabled(self):
junie = get_integration("junie")
content = "---\nallowPromptArgument: false\n---\n$ARGUMENTS\n"
out = junie.post_process_command_content(content)
assert out == "---\nallowPromptArgument: true\n---\n$prompt\n"
body = "---\ndescription: x\n---\nallowPromptArgument: no\n$ARGUMENTS\n"
out = junie.post_process_command_content(body)
assert out.startswith("---\nallowPromptArgument: true\ndescription: x\n---\n")

def test_junie_setup_leaves_no_stray_dollar_arguments(self, tmp_path):
"""Installed commands only reference Junie's own ``$prompt`` token."""
import re
from specify_cli.integrations.manifest import IntegrationManifest

junie = get_integration("junie")
m = IntegrationManifest("junie", tmp_path)
junie.setup(tmp_path, m, script_type="sh")
files = list((tmp_path / ".junie" / "commands").glob("*.md"))
assert files
for f in files:
text = f.read_text(encoding="utf-8")
assert set(re.findall(r"\$[A-Za-z_][A-Za-z0-9_-]*", text)) <= {"$prompt"}, f
assert "allowPromptArgument: true" in text

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

def test_setup_creates_files(self, tmp_path):
Expand Down
17 changes: 16 additions & 1 deletion tests/specify_cli/workflows/test_catalog_versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import hashlib
import io
import time
import zipfile

import pytest
Expand Down Expand Up @@ -49,10 +50,24 @@ def _archive(version: str, workflow_id: str = "history-wf", requires=None) -> by
document["requires"] = requires
output = io.BytesIO()
with zipfile.ZipFile(output, "w") as archive:
archive.writestr("workflow.yml", yaml.safe_dump(document))
archive.writestr(
zipfile.ZipInfo("workflow.yml", date_time=(2020, 1, 1, 0, 0, 0)),
yaml.safe_dump(document),
)
Comment on lines +53 to +56
return output.getvalue()


def test_archive_is_independent_of_wall_clock(monkeypatch):
archives = []
for second in (2, 4):
wall_clock = time.struct_time((2026, 9, 30, 14, 39, second, 2, 273, -1))
monkeypatch.setattr(
zipfile.time, "localtime", lambda timestamp=None, value=wall_clock: value
)
archives.append(_archive("1.0.0", requires={"integrations": ["copilot"]}))
assert archives[0] == archives[1]


def _entry() -> dict:
old = _archive("1.0.0", requires={"integrations": ["copilot"]})
current = _archive("2.0.0")
Expand Down
Loading