diff --git a/scripts/python/check_prerequisites.py b/scripts/python/check_prerequisites.py new file mode 100644 index 0000000000..50c31cb513 --- /dev/null +++ b/scripts/python/check_prerequisites.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Consolidated prerequisite checking script.""" + +from __future__ import annotations + +import json +import sys +from dataclasses import dataclass +from pathlib import Path + +try: + from common import FeaturePaths, format_speckit_command, get_feature_paths +except ImportError: # pragma: no cover - direct execution from unusual cwd + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from common import FeaturePaths, format_speckit_command, get_feature_paths + + +def _json_line(payload: object) -> str: + return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n" + + +HELP_TEXT = """Usage: check_prerequisites.py [OPTIONS] + +Consolidated prerequisite checking for Spec-Driven Development workflow. + +OPTIONS: + --json Output in JSON format + --require-tasks Require tasks.md to exist (for implementation phase) + --include-tasks Include tasks.md in AVAILABLE_DOCS list + --paths-only Only output path variables (no prerequisite validation) + --help, -h Show this help message + +EXAMPLES: + # Check task prerequisites (plan.md required) + ./check_prerequisites.py --json + + # Check implementation prerequisites (plan.md + tasks.md required) + ./check_prerequisites.py --json --require-tasks --include-tasks + + # Get feature paths only (no validation) + ./check_prerequisites.py --paths-only + +""" + + +@dataclass(frozen=True) +class Args: + json_mode: bool = False + require_tasks: bool = False + include_tasks: bool = False + paths_only: bool = False + + +def _parse_args(argv: list[str]) -> Args: + json_mode = False + require_tasks = False + include_tasks = False + paths_only = False + + for arg in argv: + if arg == "--json": + json_mode = True + elif arg == "--require-tasks": + require_tasks = True + elif arg == "--include-tasks": + include_tasks = True + elif arg == "--paths-only": + paths_only = True + elif arg in {"--help", "-h"}: + sys.stdout.write(HELP_TEXT) + raise SystemExit(0) + else: + print( + f"ERROR: Unknown option '{arg}'. Use --help for usage information.", + file=sys.stderr, + ) + raise SystemExit(1) + + return Args( + json_mode=json_mode, + require_tasks=require_tasks, + include_tasks=include_tasks, + paths_only=paths_only, + ) + + +def _dir_has_entries(path: Path) -> bool: + try: + return path.is_dir() and any(path.iterdir()) + except OSError: + return False + + +def _available_docs(paths: FeaturePaths, include_tasks: bool) -> list[str]: + docs: list[str] = [] + if paths.research.is_file(): + docs.append("research.md") + if paths.data_model.is_file(): + docs.append("data-model.md") + if _dir_has_entries(paths.contracts_dir): + docs.append("contracts/") + if paths.quickstart.is_file(): + docs.append("quickstart.md") + if include_tasks and paths.tasks.is_file(): + docs.append("tasks.md") + return docs + + +def _print_paths_only(paths: FeaturePaths, json_mode: bool) -> None: + if json_mode: + sys.stdout.write( + _json_line( + { + "REPO_ROOT": str(paths.repo_root), + "BRANCH": paths.current_branch, + "FEATURE_DIR": str(paths.feature_dir), + "FEATURE_SPEC": str(paths.feature_spec), + "IMPL_PLAN": str(paths.impl_plan), + "TASKS": str(paths.tasks), + } + ) + ) + return + + print(f"REPO_ROOT: {paths.repo_root}") + print(f"BRANCH: {paths.current_branch}") + print(f"FEATURE_DIR: {paths.feature_dir}") + print(f"FEATURE_SPEC: {paths.feature_spec}") + print(f"IMPL_PLAN: {paths.impl_plan}") + print(f"TASKS: {paths.tasks}") + + +def _check_file(path: Path, description: str) -> None: + marker = "✓" if path.is_file() else "✗" + print(f" {marker} {description}") + + +def _check_dir(path: Path, description: str) -> None: + marker = "✓" if _dir_has_entries(path) else "✗" + print(f" {marker} {description}") + + +def _print_text_results(paths: FeaturePaths, include_tasks: bool) -> None: + print(f"FEATURE_DIR:{paths.feature_dir}") + print("AVAILABLE_DOCS:") + _check_file(paths.research, "research.md") + _check_file(paths.data_model, "data-model.md") + _check_dir(paths.contracts_dir, "contracts/") + _check_file(paths.quickstart, "quickstart.md") + if include_tasks: + _check_file(paths.tasks, "tasks.md") + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(list(argv if argv is not None else sys.argv[1:])) + + try: + paths = get_feature_paths( + no_persist=args.paths_only, + script_file=Path(__file__), + ) + except SystemExit as exc: + if exc.code == 0: + return 0 + print("ERROR: Failed to resolve feature paths", file=sys.stderr) + return int(exc.code) if isinstance(exc.code, int) else 1 + + if args.paths_only: + _print_paths_only(paths, args.json_mode) + return 0 + + if not paths.feature_dir.is_dir(): + print(f"ERROR: Feature directory not found: {paths.feature_dir}", file=sys.stderr) + print( + f"Run {format_speckit_command('specify', paths.repo_root)} first to create the feature structure.", + file=sys.stderr, + ) + return 1 + + if not paths.impl_plan.is_file(): + print(f"ERROR: plan.md not found in {paths.feature_dir}", file=sys.stderr) + print( + f"Run {format_speckit_command('plan', paths.repo_root)} first to create the implementation plan.", + file=sys.stderr, + ) + return 1 + + if args.require_tasks and not paths.tasks.is_file(): + print(f"ERROR: tasks.md not found in {paths.feature_dir}", file=sys.stderr) + print( + f"Run {format_speckit_command('tasks', paths.repo_root)} first to create the task list.", + file=sys.stderr, + ) + return 1 + + docs = _available_docs(paths, args.include_tasks) + if args.json_mode: + sys.stdout.write( + _json_line({"FEATURE_DIR": str(paths.feature_dir), "AVAILABLE_DOCS": docs}) + ) + else: + _print_text_results(paths, args.include_tasks) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/python/common.py b/scripts/python/common.py new file mode 100644 index 0000000000..77c13eefbb --- /dev/null +++ b/scripts/python/common.py @@ -0,0 +1,210 @@ +"""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/