feat(cli): add py script type & Python interpreter resolution (#3278) (#3285)

* feat(cli): add `py` script type & Python interpreter resolution (#3278)

Introduce a third script variant alongside `sh`/`ps` as the foundation
for unifying workflow scripts under a single Python implementation.

- Add `"py": "Python"` to `SCRIPT_TYPE_CHOICES`; `VALID_SCRIPT_TYPES`
  consumers (init workflow step, init command, _helpers) pick it up
  automatically since they derive from that mapping.
- Add `IntegrationBase.resolve_python_interpreter()` (project venv →
  `python3` → `python`, falling back to `python3`).
- Prefix the resolved interpreter when `process_template()` expands
  `{SCRIPT}` for the `py` script type so `.py` scripts run portably
  (notably on Windows); thread `project_root` through callers so venv
  preference works.
- Make `install_scripts()` mark copied `.py` files executable too.

Includes positive and negative unit tests for interpreter resolution,
`py` template processing, the new choice, and script installation.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(cli): return repo-relative venv interpreter & correct docstring

Address PR review feedback on #3285:

- `resolve_python_interpreter()` now returns the venv interpreter as a
  path relative to the project root (`.venv/bin/python` /
  `.venv/Scripts/python.exe`) instead of an absolute/joined path, so the
  generated `{SCRIPT}` invocation stays portable and runnable from the
  repo root regardless of where the project lives.
- Update `install_scripts()` docstring to note `.py` scripts are now
  made executable alongside `.sh`.
- Update tests to assert the repo-relative interpreter path.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(cli): fall back to sys.executable for interpreter resolution

When neither python3 nor python is discoverable on PATH (and no project
venv is found), resolve_python_interpreter() now returns the running
interpreter (sys.executable) so the generated {SCRIPT} invocation works
in the current environment, falling back to "python3" only if that is
also unavailable. Update unit tests accordingly.

* fix(cli): quote py interpreter path when it contains whitespace

For the `py` script type, the resolved interpreter may be an absolute
path containing spaces (notably `sys.executable` under Windows
`Program Files`). Quote it when it contains whitespace so the `{SCRIPT}`
invocation isn't split into multiple arguments. Add positive/negative
tests for the quoting behavior.

* test: guard executable-bit assertions from Windows chmod semantics

The Windows CI job failed because `os.chmod` does not set POSIX
executable bits on Windows, so `install_scripts()` cannot make `.py`/
`.sh` files executable there (nor is it needed — the interpreter is
invoked explicitly). Split the install_scripts test so file-copy
behavior is still verified cross-platform, and skip the executable-bit
assertions on win32 (matching the repo's existing pattern).

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Manfred Riem
2026-07-01 16:34:46 -05:00
committed by GitHub
parent 3b30e40aaa
commit bbe86310ca
8 changed files with 269 additions and 4 deletions

View File

@@ -17,4 +17,8 @@ AGENT_CONFIG: dict[str, dict[str, Any]] = _build_agent_config()
DEFAULT_INIT_INTEGRATION = "copilot"
SCRIPT_TYPE_CHOICES: dict[str, str] = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"}
SCRIPT_TYPE_CHOICES: dict[str, str] = {
"sh": "POSIX Shell (bash/zsh)",
"ps": "PowerShell",
"py": "Python",
}

View File

@@ -17,6 +17,7 @@ import os
import re
import shlex
import shutil
import sys
from abc import ABC
from dataclasses import dataclass
from pathlib import Path
@@ -495,8 +496,8 @@ class IntegrationBase(ABC):
Copies files from this integration's ``scripts/`` directory to
``.specify/integrations/<key>/scripts/`` in the project. Shell
scripts are made executable. All copied files are recorded in
*manifest*.
(``.sh``) and Python (``.py``) scripts are made executable. All
copied files are recorded in *manifest*.
Returns the list of files created.
"""
@@ -513,7 +514,7 @@ class IntegrationBase(ABC):
continue
dst_script = scripts_dest / src_script.name
shutil.copy2(src_script, dst_script)
if dst_script.suffix == ".sh":
if dst_script.suffix in (".sh", ".py"):
dst_script.chmod(dst_script.stat().st_mode | 0o111)
self.record_file_in_manifest(dst_script, project_root, manifest)
created.append(dst_script)
@@ -538,6 +539,47 @@ class IntegrationBase(ABC):
content,
)
@staticmethod
def resolve_python_interpreter(project_root: Path | None = None) -> str:
"""Resolve a portable Python interpreter command for ``{SCRIPT}``.
Used to build the invocation string for the ``py`` script type so
that ``.py`` workflow scripts run consistently across platforms
(notably Windows, where ``.py`` files are not directly executable).
Resolution order:
1. A project virtual environment (``.venv``) interpreter, if one
exists under *project_root* (POSIX ``bin/python`` or Windows
``Scripts/python.exe``). The returned path is **relative to the
project root** (e.g. ``.venv/bin/python``) so generated
``{SCRIPT}`` invocations stay portable and runnable from the
repo root regardless of where the project lives.
2. ``python3`` on ``PATH``.
3. ``python`` on ``PATH``.
Falls back to the running interpreter (``sys.executable``) when
``PATH`` resolution fails so the generated command is guaranteed
to work in the current environment, and finally to ``"python3"``
if even that is unavailable.
"""
if project_root is not None:
# (existence check path, repo-root-relative invocation string)
venv_candidates = (
(project_root / ".venv" / "bin" / "python", ".venv/bin/python"),
(
project_root / ".venv" / "Scripts" / "python.exe",
".venv/Scripts/python.exe",
),
)
for candidate, relative in venv_candidates:
if candidate.exists():
return relative
for name in ("python3", "python"):
if shutil.which(name):
return name
return sys.executable or "python3"
@staticmethod
def process_template(
content: str,
@@ -545,6 +587,7 @@ class IntegrationBase(ABC):
script_type: str,
arg_placeholder: str = "$ARGUMENTS",
invoke_separator: str = ".",
project_root: Path | None = None,
) -> str:
"""Process a raw command template into agent-ready content.
@@ -578,6 +621,17 @@ class IntegrationBase(ABC):
# 2. Replace {SCRIPT}
if script_command:
# For the Python script type, prefix the resolved interpreter so
# the command is portable (``.py`` files are not directly
# executable on Windows).
if script_type == "py":
interpreter = IntegrationBase.resolve_python_interpreter(project_root)
# Quote the interpreter if it contains whitespace (e.g. an
# absolute ``sys.executable`` path under Windows
# ``Program Files``) so it isn't split into multiple args.
if any(ch.isspace() for ch in interpreter):
interpreter = f'"{interpreter}"'
script_command = f"{interpreter} {script_command}"
content = content.replace("{SCRIPT}", script_command)
# 3. Strip scripts: section from frontmatter
@@ -784,6 +838,7 @@ class MarkdownIntegration(IntegrationBase):
raw = src_file.read_text(encoding="utf-8")
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
project_root=project_root,
)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(
@@ -986,6 +1041,7 @@ class TomlIntegration(IntegrationBase):
description = self._extract_description(raw)
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
project_root=project_root,
)
_, body = self._split_frontmatter(processed)
toml_content = self._render_toml(description, body)
@@ -1186,6 +1242,7 @@ class YamlIntegration(IntegrationBase):
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
project_root=project_root,
)
_, body = self._split_frontmatter(processed)
yaml_content = self._render_yaml(
@@ -1381,6 +1438,7 @@ class SkillsIntegration(IntegrationBase):
# Process body through the standard template pipeline
processed_body = self.process_template(
raw, self.key, script_type, arg_placeholder,
project_root=project_root,
invoke_separator=self.invoke_separator,
)
# Strip the processed frontmatter — we rebuild it for skills.

View File

@@ -370,6 +370,7 @@ class CopilotIntegration(IntegrationBase):
raw = src_file.read_text(encoding="utf-8")
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
project_root=project_root,
)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(

View File

@@ -134,6 +134,7 @@ class ForgeIntegration(MarkdownIntegration):
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
invoke_separator=self.invoke_separator,
project_root=project_root,
)
# FORGE-SPECIFIC: Ensure any remaining $ARGUMENTS placeholders are

View File

@@ -123,6 +123,7 @@ class GenericIntegration(MarkdownIntegration):
raw = src_file.read_text(encoding="utf-8")
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
project_root=project_root,
)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(

View File

@@ -140,6 +140,7 @@ class HermesIntegration(SkillsIntegration):
script_type,
arg_placeholder,
invoke_separator=self.invoke_separator,
project_root=project_root,
)
# Strip the processed frontmatter — we rebuild it for skills.
if processed_body.startswith("---"):