mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
* feat(scripts): port create-new-feature, setup-plan and setup-tasks to Python Ports the three core workflow scripts to Python as part of #3280, following the check-prerequisites PoC pattern from #3302. Adds resolve_template() to the shared common.py module and parity tests that run bash and Python side by side. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(tests): treat only None env as unset in parity run helper Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): fall back to directory scan on any registry error, skip hidden preset dirs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(templates): add py: lines for setup_plan and setup_tasks Ships with the scripts they reference; the remaining templates got their py: lines in #3403. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: support py variant in skills placeholder resolver resolve_skill_placeholders only accepted sh/ps, so a py init option fell into the fallback path and {SCRIPT} rendered without an interpreter prefix. Accept py and prefix the resolved interpreter, matching process_template. Also guard ps_cmd against a missing PowerShell with a clear assert. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: pin clean-error behavior for invalid --number Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(scripts): reword unused-arg comment to match implementation The loop accepts and silently ignores extra positional args (it doesn't build a collected list); match the wording to what the code and setup-plan.sh actually do. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: fall back when configured script variant is missing from frontmatter Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): reject signed/whitespace --number values to match bash 10# parity The bash twin uses $((10#$BRANCH_NUMBER)), which rejects signed and whitespace-padded values. Python's int() accepted them (e.g. -1), producing a malformed -01-... prefix that sequential scans ignore. Restrict --number to unsigned decimal digits before conversion, and pin the parity with a bash-comparison test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): complete Python port installation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(integrations): fall back for missing script variants Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: make Python script checks platform-aware Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix Windows Python command invocation parity Use PowerShell's call operator for spaced Python interpreter paths and align setup-tasks missing-template errors across script variants. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): preserve cross-platform Python parity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: reject signed PowerShell feature numbers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): align feature number range Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): reject exhausted feature numbers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): complete create feature parity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): align create feature outputs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): harden cross-platform parity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): keep truncation JSON clean Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): align setup failure parity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): close parity edge cases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): propagate PowerShell setup errors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): harden fallback resolution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): stabilize PowerShell fallbacks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): complete setup-plan parity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(cli): require runnable script fallbacks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(cli): preserve shell fallback without preference Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): restore help and symlink parity - setup-tasks.ps1: check -Help before unknown-argument validation so '-Help --bogus' exits 0 like the Bash/Python variants - common.py: strip the repo root prefix lexically in persist_feature_json instead of resolve(), so a symlinked specs/ still persists the relative 'specs/NNN-name' path the Bash/PowerShell helpers store Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): align persist-hint quoting with shlex.quote - create-new-feature.sh: replace printf %q with a shell_quote helper that emits shlex.quote-identical output, so the persistence hints stay byte-identical between the Bash and Python variants (printf %q output also varies between bash versions) - promote the negative --number test to an all-variants parity test now that Bash and PowerShell reject signed values consistently - add a spaced-repo-path parity test for the persistence hints Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
283 lines
9.2 KiB
Python
283 lines
9.2 KiB
Python
"""Shared helpers for Spec Kit Python scripts."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
|
|
def _trim_trailing_separators(value: Path) -> str:
|
|
text = str(value)
|
|
while len(text) > 1 and text.endswith((os.sep, "/")):
|
|
text = text[:-1]
|
|
return text
|
|
|
|
|
|
def find_specify_root(start_dir: Path | None = None) -> Path | None:
|
|
current = (start_dir or Path.cwd()).resolve()
|
|
while True:
|
|
if (current / ".specify").is_dir():
|
|
return current
|
|
parent = current.parent
|
|
if parent == current:
|
|
return None
|
|
current = parent
|
|
|
|
|
|
def resolve_specify_init_dir() -> Path:
|
|
raw = os.environ.get("SPECIFY_INIT_DIR", "")
|
|
candidate = Path(raw)
|
|
if not candidate.is_absolute():
|
|
candidate = Path.cwd() / candidate
|
|
try:
|
|
init_root = candidate.resolve(strict=True)
|
|
except OSError:
|
|
print(
|
|
f"ERROR: SPECIFY_INIT_DIR does not point to an existing directory: {raw}",
|
|
file=sys.stderr,
|
|
)
|
|
raise SystemExit(1)
|
|
if not init_root.is_dir():
|
|
print(
|
|
f"ERROR: SPECIFY_INIT_DIR does not point to an existing directory: {raw}",
|
|
file=sys.stderr,
|
|
)
|
|
raise SystemExit(1)
|
|
if not (init_root / ".specify").is_dir():
|
|
print(
|
|
"ERROR: SPECIFY_INIT_DIR is not a Spec Kit project "
|
|
f"(no .specify/ directory): {init_root}",
|
|
file=sys.stderr,
|
|
)
|
|
raise SystemExit(1)
|
|
return init_root
|
|
|
|
|
|
def get_repo_root(script_file: Path | None = None) -> Path:
|
|
if os.environ.get("SPECIFY_INIT_DIR"):
|
|
return resolve_specify_init_dir()
|
|
|
|
specify_root = find_specify_root()
|
|
if specify_root is not None:
|
|
return specify_root
|
|
|
|
if script_file is not None:
|
|
script_root = find_specify_root(script_file.resolve().parent)
|
|
if script_root is not None:
|
|
return script_root
|
|
|
|
# Installed scripts live at .specify/scripts/python/<script>.py.
|
|
return script_file.resolve().parents[3]
|
|
return Path.cwd().resolve()
|
|
|
|
|
|
def get_current_branch() -> str:
|
|
return os.environ.get("SPECIFY_FEATURE", "")
|
|
|
|
|
|
def read_feature_json_feature_directory(repo_root: Path) -> str:
|
|
feature_json = repo_root / ".specify" / "feature.json"
|
|
if not feature_json.is_file():
|
|
return ""
|
|
try:
|
|
data = json.loads(feature_json.read_text(encoding="utf-8"))
|
|
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
return ""
|
|
value = data.get("feature_directory") if isinstance(data, dict) else None
|
|
return value if isinstance(value, str) else ""
|
|
|
|
|
|
def _json_dump(data: dict[str, str]) -> str:
|
|
return json.dumps(data, ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
|
|
|
|
def persist_feature_json(repo_root: Path, feature_dir_value: str) -> None:
|
|
# Strip the repo root prefix lexically (no resolve()) to mirror the
|
|
# Bash/PowerShell helpers: with a symlinked <repo>/specs, resolve() would
|
|
# escape the repo and persist a machine-specific absolute path instead of
|
|
# the relative "specs/NNN-name" the other variants store.
|
|
value = feature_dir_value
|
|
relative = Path(value)
|
|
if relative.is_absolute():
|
|
try:
|
|
value = relative.relative_to(repo_root).as_posix()
|
|
except ValueError:
|
|
value = str(relative)
|
|
|
|
current = read_feature_json_feature_directory(repo_root)
|
|
if current == value:
|
|
return
|
|
|
|
specify_dir = repo_root / ".specify"
|
|
specify_dir.mkdir(parents=True, exist_ok=True)
|
|
(specify_dir / "feature.json").write_bytes(
|
|
_json_dump({"feature_directory": value}).encode("utf-8")
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FeaturePaths:
|
|
repo_root: Path
|
|
current_branch: str
|
|
feature_dir: Path
|
|
feature_spec: Path
|
|
impl_plan: Path
|
|
tasks: Path
|
|
research: Path
|
|
data_model: Path
|
|
quickstart: Path
|
|
contracts_dir: Path
|
|
|
|
|
|
def get_feature_paths(
|
|
*, no_persist: bool = False, script_file: Path | None = None
|
|
) -> FeaturePaths:
|
|
repo_root = get_repo_root(script_file)
|
|
current_branch = get_current_branch()
|
|
|
|
feature_dir_raw = os.environ.get("SPECIFY_FEATURE_DIRECTORY", "")
|
|
if feature_dir_raw:
|
|
feature_dir = Path(feature_dir_raw)
|
|
if not feature_dir.is_absolute():
|
|
feature_dir = repo_root / feature_dir
|
|
if not no_persist:
|
|
persist_feature_json(repo_root, feature_dir_raw)
|
|
elif (repo_root / ".specify" / "feature.json").is_file():
|
|
stored = read_feature_json_feature_directory(repo_root)
|
|
if not stored:
|
|
print(
|
|
"ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY "
|
|
"or ensure .specify/feature.json contains feature_directory.",
|
|
file=sys.stderr,
|
|
)
|
|
raise SystemExit(1)
|
|
feature_dir = Path(stored)
|
|
if not feature_dir.is_absolute():
|
|
feature_dir = repo_root / feature_dir
|
|
else:
|
|
print(
|
|
"ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY "
|
|
"or run the specify command to create .specify/feature.json.",
|
|
file=sys.stderr,
|
|
)
|
|
raise SystemExit(1)
|
|
|
|
if not current_branch:
|
|
current_branch = Path(_trim_trailing_separators(feature_dir)).name
|
|
|
|
return FeaturePaths(
|
|
repo_root=repo_root,
|
|
current_branch=current_branch,
|
|
feature_dir=feature_dir,
|
|
feature_spec=feature_dir / "spec.md",
|
|
impl_plan=feature_dir / "plan.md",
|
|
tasks=feature_dir / "tasks.md",
|
|
research=feature_dir / "research.md",
|
|
data_model=feature_dir / "data-model.md",
|
|
quickstart=feature_dir / "quickstart.md",
|
|
contracts_dir=feature_dir / "contracts",
|
|
)
|
|
|
|
|
|
def _sorted_preset_ids(presets_dir: Path) -> list[str]:
|
|
registry = presets_dir / ".registry"
|
|
if registry.is_file():
|
|
# Mirrors bash: any failure while reading or sorting the registry
|
|
# (invalid JSON, non-dict shapes, unorderable priority values) falls
|
|
# back to the directory scan below.
|
|
try:
|
|
data = json.loads(registry.read_text(encoding="utf-8"))
|
|
presets = data.get("presets", {})
|
|
return [
|
|
pid
|
|
for pid, meta in sorted(
|
|
presets.items(),
|
|
key=lambda kv: kv[1].get("priority", 10)
|
|
if isinstance(kv[1], dict)
|
|
else 10,
|
|
)
|
|
if isinstance(meta, dict) and meta.get("enabled", True) is not False
|
|
]
|
|
except Exception:
|
|
pass
|
|
try:
|
|
return sorted(
|
|
p.name
|
|
for p in presets_dir.iterdir()
|
|
if p.is_dir() and not p.name.startswith(".")
|
|
)
|
|
except OSError:
|
|
return []
|
|
|
|
|
|
def resolve_template(template_name: str, repo_root: Path) -> Path | None:
|
|
"""Resolve a template name to a file path using the priority stack.
|
|
|
|
Order (mirrors resolve_template in scripts/bash/common.sh):
|
|
1. .specify/templates/overrides/
|
|
2. .specify/presets/<preset-id>/templates/ (sorted by .registry priority)
|
|
3. .specify/extensions/<ext-id>/templates/ (hidden directories skipped)
|
|
4. .specify/templates/ (core)
|
|
"""
|
|
base = repo_root / ".specify" / "templates"
|
|
|
|
override = base / "overrides" / f"{template_name}.md"
|
|
if override.is_file():
|
|
return override
|
|
|
|
presets_dir = repo_root / ".specify" / "presets"
|
|
if presets_dir.is_dir():
|
|
for preset_id in _sorted_preset_ids(presets_dir):
|
|
candidate = presets_dir / preset_id / "templates" / f"{template_name}.md"
|
|
if candidate.is_file():
|
|
return candidate
|
|
|
|
ext_dir = repo_root / ".specify" / "extensions"
|
|
if ext_dir.is_dir():
|
|
try:
|
|
extensions = sorted(p for p in ext_dir.iterdir() if p.is_dir())
|
|
except OSError:
|
|
extensions = []
|
|
for ext in extensions:
|
|
if ext.name.startswith("."):
|
|
continue
|
|
candidate = ext / "templates" / f"{template_name}.md"
|
|
if candidate.is_file():
|
|
return candidate
|
|
|
|
core = base / f"{template_name}.md"
|
|
if core.is_file():
|
|
return core
|
|
return None
|
|
|
|
|
|
def get_invoke_separator(repo_root: Path) -> str:
|
|
integration_json = repo_root / ".specify" / "integration.json"
|
|
if not integration_json.is_file():
|
|
return "."
|
|
try:
|
|
state = json.loads(integration_json.read_text(encoding="utf-8"))
|
|
key = state.get("default_integration") or state.get("integration") or ""
|
|
settings = state.get("integration_settings")
|
|
if isinstance(key, str) and isinstance(settings, dict):
|
|
entry = settings.get(key)
|
|
if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}:
|
|
return entry["invoke_separator"]
|
|
except (OSError, json.JSONDecodeError):
|
|
pass
|
|
return "."
|
|
|
|
|
|
def format_speckit_command(command_name: str, repo_root: Path) -> str:
|
|
separator = get_invoke_separator(repo_root)
|
|
name = command_name.lstrip("/")
|
|
if name.startswith("speckit."):
|
|
name = name[len("speckit.") :]
|
|
elif name.startswith("speckit-"):
|
|
name = name[len("speckit-") :]
|
|
name = name.replace(".", separator)
|
|
return f"/speckit{separator}{name}"
|