mirror of
https://github.com/github/spec-kit.git
synced 2026-07-07 06:35:06 +08:00
* 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>
207 lines
7.3 KiB
Python
207 lines
7.3 KiB
Python
"""Forge integration — forgecode.dev AI coding agent.
|
|
|
|
Forge has several unique behaviors compared to standard markdown agents:
|
|
- Uses `{{parameters}}` instead of `$ARGUMENTS` for argument passing
|
|
- Strips `handoffs` frontmatter key (Claude Code feature that causes Forge to hang)
|
|
- Injects `name` field into frontmatter when missing
|
|
- Uses a hyphenated frontmatter `name` value (e.g., `speckit-foo-bar`) for shell compatibility, especially with ZSH
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from ..base import MarkdownIntegration
|
|
from ..manifest import IntegrationManifest
|
|
|
|
|
|
def format_forge_command_name(cmd_name: str) -> str:
|
|
"""Convert command name to Forge-compatible hyphenated format.
|
|
|
|
Forge requires command names to use hyphens instead of dots for
|
|
compatibility with ZSH and other shells. This function converts
|
|
dot-notation command names to hyphenated format.
|
|
|
|
The function is idempotent: already-formatted names are returned unchanged.
|
|
|
|
Examples:
|
|
>>> format_forge_command_name("plan")
|
|
'speckit-plan'
|
|
>>> format_forge_command_name("speckit.plan")
|
|
'speckit-plan'
|
|
>>> format_forge_command_name("speckit-plan")
|
|
'speckit-plan'
|
|
>>> format_forge_command_name("speckit.my-extension.example")
|
|
'speckit-my-extension-example'
|
|
>>> format_forge_command_name("speckit-my-extension-example")
|
|
'speckit-my-extension-example'
|
|
>>> format_forge_command_name("speckit.jira.sync-status")
|
|
'speckit-jira-sync-status'
|
|
|
|
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
|
|
"""
|
|
# Already in hyphenated format - return as-is (idempotent)
|
|
if cmd_name.startswith("speckit-"):
|
|
return cmd_name
|
|
|
|
# Strip 'speckit.' prefix if present
|
|
short_name = cmd_name
|
|
if short_name.startswith("speckit."):
|
|
short_name = short_name[len("speckit."):]
|
|
|
|
# Replace all dots with hyphens
|
|
short_name = short_name.replace(".", "-")
|
|
|
|
# Return with 'speckit-' prefix
|
|
return f"speckit-{short_name}"
|
|
|
|
|
|
class ForgeIntegration(MarkdownIntegration):
|
|
"""Integration for Forge (forgecode.dev).
|
|
|
|
Extends MarkdownIntegration to add Forge-specific processing:
|
|
- Replaces $ARGUMENTS with {{parameters}}
|
|
- Strips 'handoffs' frontmatter key (incompatible with Forge)
|
|
- Injects 'name' field into frontmatter when missing
|
|
"""
|
|
|
|
key = "forge"
|
|
config = {
|
|
"name": "Forge",
|
|
"folder": ".forge/",
|
|
"commands_subdir": "commands",
|
|
"install_url": "https://forgecode.dev/docs/",
|
|
"requires_cli": True,
|
|
}
|
|
registrar_config = {
|
|
"dir": ".forge/commands",
|
|
"format": "markdown",
|
|
"args": "{{parameters}}",
|
|
"extension": ".md",
|
|
"strip_frontmatter_keys": ["handoffs"],
|
|
"inject_name": True,
|
|
"format_name": format_forge_command_name, # Custom name formatter
|
|
"invoke_separator": "-",
|
|
}
|
|
invoke_separator = "-"
|
|
|
|
def setup(
|
|
self,
|
|
project_root: Path,
|
|
manifest: IntegrationManifest,
|
|
parsed_options: dict[str, Any] | None = None,
|
|
**opts: Any,
|
|
) -> list[Path]:
|
|
"""Install Forge commands with custom processing.
|
|
|
|
Extends MarkdownIntegration.setup() to inject Forge-specific transformations
|
|
after standard template processing.
|
|
"""
|
|
templates = self.list_command_templates()
|
|
if not templates:
|
|
return []
|
|
|
|
project_root_resolved = project_root.resolve()
|
|
if manifest.project_root != project_root_resolved:
|
|
raise ValueError(
|
|
f"manifest.project_root ({manifest.project_root}) does not match "
|
|
f"project_root ({project_root_resolved})"
|
|
)
|
|
|
|
dest = self.commands_dest(project_root).resolve()
|
|
try:
|
|
dest.relative_to(project_root_resolved)
|
|
except ValueError as exc:
|
|
raise ValueError(
|
|
f"Integration destination {dest} escapes "
|
|
f"project root {project_root_resolved}"
|
|
) from exc
|
|
dest.mkdir(parents=True, exist_ok=True)
|
|
|
|
script_type = opts.get("script_type", "sh")
|
|
arg_placeholder = self.registrar_config.get("args", "{{parameters}}")
|
|
created: list[Path] = []
|
|
|
|
for src_file in templates:
|
|
raw = src_file.read_text(encoding="utf-8")
|
|
# Process template with standard MarkdownIntegration logic
|
|
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
|
|
# converted to {{parameters}}
|
|
processed = processed.replace("$ARGUMENTS", arg_placeholder)
|
|
|
|
# FORGE-SPECIFIC: Apply frontmatter transformations
|
|
processed = self._apply_forge_transformations(processed, src_file.stem)
|
|
|
|
dst_name = self.command_filename(src_file.stem)
|
|
dst_file = self.write_file_and_record(
|
|
processed, dest / dst_name, project_root, manifest
|
|
)
|
|
created.append(dst_file)
|
|
|
|
|
|
return created
|
|
|
|
def _apply_forge_transformations(self, content: str, template_name: str) -> str:
|
|
"""Apply Forge-specific transformations to processed content.
|
|
|
|
1. Strip 'handoffs' frontmatter key (from Claude Code templates; incompatible with Forge)
|
|
2. Inject 'name' field if missing (using hyphenated format)
|
|
"""
|
|
# Parse frontmatter
|
|
lines = content.split('\n')
|
|
if not lines or lines[0].strip() != '---':
|
|
return content
|
|
|
|
# Find end of frontmatter
|
|
frontmatter_end = -1
|
|
for i in range(1, len(lines)):
|
|
if lines[i].strip() == '---':
|
|
frontmatter_end = i
|
|
break
|
|
|
|
if frontmatter_end == -1:
|
|
return content
|
|
|
|
frontmatter_lines = lines[1:frontmatter_end]
|
|
body_lines = lines[frontmatter_end + 1:]
|
|
|
|
# 1. Strip 'handoffs' key
|
|
filtered_frontmatter = []
|
|
skip_until_outdent = False
|
|
for line in frontmatter_lines:
|
|
if skip_until_outdent:
|
|
# Skip indented lines under handoffs:
|
|
if line and (line[0] == ' ' or line[0] == '\t'):
|
|
continue
|
|
else:
|
|
skip_until_outdent = False
|
|
|
|
if line.strip().startswith('handoffs:'):
|
|
skip_until_outdent = True
|
|
continue
|
|
|
|
filtered_frontmatter.append(line)
|
|
|
|
# 2. Inject 'name' field if missing (using centralized formatter)
|
|
has_name = any(line.strip().startswith('name:') for line in filtered_frontmatter)
|
|
if not has_name:
|
|
# Use centralized formatter to ensure consistent hyphenated format
|
|
cmd_name = format_forge_command_name(template_name)
|
|
filtered_frontmatter.insert(0, f'name: {cmd_name}')
|
|
|
|
# Reconstruct content
|
|
result = ['---'] + filtered_frontmatter + ['---'] + body_lines
|
|
return '\n'.join(result)
|