diff --git a/src/specify_cli/integrations/junie/__init__.py b/src/specify_cli/integrations/junie/__init__.py index 2d4a6b32d9..2851e1ce8d 100644 --- a/src/specify_cli/integrations/junie/__init__.py +++ b/src/specify_cli/integrations/junie/__init__.py @@ -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. @@ -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, diff --git a/tests/integrations/test_integration_junie.py b/tests/integrations/test_integration_junie.py index a6234ba734..84232f1274 100644 --- a/tests/integrations/test_integration_junie.py +++ b/tests/integrations/test_integration_junie.py @@ -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): diff --git a/tests/specify_cli/workflows/test_catalog_versions.py b/tests/specify_cli/workflows/test_catalog_versions.py index f9828b3278..005c5989d0 100644 --- a/tests/specify_cli/workflows/test_catalog_versions.py +++ b/tests/specify_cli/workflows/test_catalog_versions.py @@ -4,6 +4,7 @@ import hashlib import io +import time import zipfile import pytest @@ -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), + ) 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")