Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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: 3 additions & 2 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,9 @@ def _install_shared_infra(

Copies ``.specify/scripts/<variant>/`` and ``.specify/templates/`` from
the bundled core_pack or source checkout, where ``<variant>`` is
``bash`` when *script_type* is ``"sh"`` and ``powershell`` when it is
``"ps"``. Tracks all installed files in ``speckit.manifest.json``.
``bash`` when *script_type* is ``"sh"``, ``python`` when it is ``"py"``,
and ``powershell`` when it is ``"ps"``. Tracks all installed files in
``speckit.manifest.json``.

Shared scripts and page templates are processed to resolve
``__SPECKIT_COMMAND_<NAME>__`` placeholders using *invoke_separator*
Expand Down
6 changes: 5 additions & 1 deletion src/specify_cli/shared_infra.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ def _is_managed(rel: str, dst: Path) -> bool:
# manifest entries the core no longer ships (stale-script cleanup, #3076).
seen_rels: set[str] = set()
scripts_scanned = False
variant_dir = "bash" if script_type == "sh" else "powershell"
variant_dir = {"sh": "bash", "py": "python"}.get(script_type, "powershell")

def _decide_overwrite(rel: str, dst: Path) -> tuple[bool, str | None]:
"""Return (write, bucket) where bucket is 'skip', 'preserved', or None."""
Expand Down Expand Up @@ -462,6 +462,10 @@ def _ensure_or_bucket_dir(directory: Path) -> bool:
for src_path in variant_src.rglob("*"):
if not src_path.is_file():
continue
# Python bytecode caches are local artifacts, not
# workflow scripts — never install them.
if "__pycache__" in src_path.parts:
continue
# Mark scanned only once a real source file is seen. An
# empty (or symlink-skipped) variant keeps this False, so
# stale-cleanup is skipped — otherwise it would treat every
Expand Down
1 change: 1 addition & 0 deletions templates/commands/analyze.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ description: Perform a non-destructive cross-artifact consistency and quality an
scripts:
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
---

## User Input
Expand Down
1 change: 1 addition & 0 deletions templates/commands/checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ description: Generate a custom checklist for the current feature based on user r
scripts:
sh: scripts/bash/check-prerequisites.sh --json
ps: scripts/powershell/check-prerequisites.ps1 -Json
py: scripts/python/check_prerequisites.py --json
---

## Checklist Purpose: "Unit Tests for English"
Expand Down
1 change: 1 addition & 0 deletions templates/commands/clarify.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ handoffs:
scripts:
sh: scripts/bash/check-prerequisites.sh --json --paths-only
ps: scripts/powershell/check-prerequisites.ps1 -Json -PathsOnly
py: scripts/python/check_prerequisites.py --json --paths-only
---

## User Input
Expand Down
1 change: 1 addition & 0 deletions templates/commands/converge.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ description: Assess the current codebase against the feature's spec, plan, and t
scripts:
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
---

## User Input
Expand Down
1 change: 1 addition & 0 deletions templates/commands/implement.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ description: Execute the implementation plan by processing and executing all tas
scripts:
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
Comment thread
mnriem marked this conversation as resolved.
---

## User Input
Expand Down
1 change: 1 addition & 0 deletions templates/commands/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ handoffs:
scripts:
sh: scripts/bash/setup-plan.sh --json
ps: scripts/powershell/setup-plan.ps1 -Json
py: scripts/python/setup_plan.py --json
Comment thread
mnriem marked this conversation as resolved.
Outdated
Comment thread
mnriem marked this conversation as resolved.
Outdated
---

## User Input
Expand Down
1 change: 1 addition & 0 deletions templates/commands/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ handoffs:
scripts:
sh: scripts/bash/setup-tasks.sh --json
ps: scripts/powershell/setup-tasks.ps1 -Json
py: scripts/python/setup_tasks.py --json
Comment thread
mnriem marked this conversation as resolved.
Outdated
Comment thread
mnriem marked this conversation as resolved.
Outdated
---

## User Input
Expand Down
1 change: 1 addition & 0 deletions templates/commands/taskstoissues.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ tools: ['github/github-mcp-server/list_issues', 'github/github-mcp-server/issue_
scripts:
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
---

## User Input
Expand Down
83 changes: 83 additions & 0 deletions tests/test_command_template_py_scripts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""Every command template with a scripts: block must render for --script py.

Covers #3283: each ``templates/commands/*.md`` that declares a ``scripts:``
frontmatter block carries a ``py:`` line, and ``process_template`` turns it
into a valid Python invocation (interpreter-prefixed, path rewritten to the
``.specify`` tree).
"""

import re
from pathlib import Path

import pytest

from specify_cli.integrations.base import IntegrationBase

TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "commands"

SCRIPTED_TEMPLATES = sorted(
p.name for p in TEMPLATES_DIR.glob("*.md") if "\nscripts:\n" in p.read_text(encoding="utf-8")
)


@pytest.fixture(autouse=True)
def _pin_interpreter(monkeypatch):
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which",
lambda name: "/usr/bin/python3" if name == "python3" else None,
)


def test_scripted_templates_discovered():
# Guard: the glob must find the known scripted templates, otherwise the
# parametrized tests below would silently pass on an empty set.
assert "plan.md" in SCRIPTED_TEMPLATES
assert "implement.md" in SCRIPTED_TEMPLATES


@pytest.mark.parametrize("name", SCRIPTED_TEMPLATES)
def test_template_declares_py_script(name: str):
content = (TEMPLATES_DIR / name).read_text(encoding="utf-8")
assert re.search(r"^\s*py: scripts/python/\S+\.py", content, re.MULTILINE), (
f"{name} has a scripts: block but no py: line"
)
Comment thread
mnriem marked this conversation as resolved.
Outdated


@pytest.mark.parametrize("name", SCRIPTED_TEMPLATES)
def test_template_renders_python_invocation(name: str):
content = (TEMPLATES_DIR / name).read_text(encoding="utf-8")
result = IntegrationBase.process_template(content, "agent", "py")
assert "{SCRIPT}" not in result
assert re.search(
r"python3 \.specify/scripts/python/\w+\.py(?: --[\w-]+)*", result
), f"{name} did not render a Python invocation"


@pytest.mark.parametrize("name", SCRIPTED_TEMPLATES)
def test_sh_rendering_unchanged(name: str):
# Negative: adding py: lines must not leak into sh rendering.
content = (TEMPLATES_DIR / name).read_text(encoding="utf-8")
result = IntegrationBase.process_template(content, "agent", "sh")
assert "{SCRIPT}" not in result
assert "scripts/python" not in result


def test_install_shared_infra_copies_python_scripts(tmp_path):
# --script py must install scripts/python/ into .specify/scripts/python/
# so the rendered invocations point at files that exist.
from rich.console import Console

from specify_cli.shared_infra import install_shared_infra

install_shared_infra(
tmp_path,
"py",
version="0.0.0",
core_pack=None,
repo_root=Path(__file__).resolve().parents[1],
console=Console(quiet=True),
force=False,
)
dest = tmp_path / ".specify" / "scripts" / "python"
assert (dest / "check_prerequisites.py").is_file()
assert not (tmp_path / ".specify" / "scripts" / "powershell").exists()