MCPcopy
hub / github.com/github/spec-kit / StepRegistry

Class StepRegistry

src/specify_cli/workflows/catalog.py:626–729  ·  view source on GitHub ↗

Manages the registry of installed custom step types. Tracks installed step types and their metadata in ``.specify/workflows/steps/step-registry.json``.

Source from the content-addressed store, hash-verified

624
625
626class StepRegistry:
627 """Manages the registry of installed custom step types.
628
629 Tracks installed step types and their metadata in
630 ``.specify/workflows/steps/step-registry.json``.
631 """
632
633 REGISTRY_FILE = "step-registry.json"
634 SCHEMA_VERSION = "1.0"
635
636 def __init__(self, project_root: Path) -> None:
637 self.project_root = project_root
638 self.steps_dir = project_root / ".specify" / "workflows" / "steps"
639 self.registry_path = self.steps_dir / self.REGISTRY_FILE
640 self.data = self._load()
641
642 def _has_symlinked_parent(self) -> bool:
643 """Return True if any directory under .specify/workflows/steps is a symlink."""
644 current = self.project_root
645 for part in (".specify", "workflows", "steps"):
646 current = current / part
647 if current.is_symlink():
648 return True
649 return False
650
651 def _load(self) -> dict[str, Any]:
652 """Load registry from disk or create default."""
653 default_registry: dict[str, Any] = {"schema_version": self.SCHEMA_VERSION, "steps": {}}
654 # Defense-in-depth: refuse to read the registry if any parent directory
655 # under .specify/workflows/steps is a symlink, which could redirect the
656 # read outside the project root.
657 if self._has_symlinked_parent():
658 return default_registry
659 # Defense-in-depth: also refuse to read a symlinked registry file,
660 # which could redirect the read outside the project root.
661 if self.registry_path.is_symlink():
662 return default_registry
663 if self.registry_path.exists():
664 try:
665 with open(self.registry_path, encoding="utf-8") as f:
666 data = json.load(f)
667 # Validate shape: must be a dict with a dict "steps" field
668 if not isinstance(data, dict):
669 return default_registry
670 if not isinstance(data.get("steps"), dict):
671 data["steps"] = {}
672 return data
673 except (json.JSONDecodeError, ValueError, OSError, UnicodeError):
674 return default_registry
675 return default_registry
676
677 def save(self) -> None:
678 """Persist registry to disk.
679
680 Raises ``StepValidationError`` with a clear message on filesystem
681 errors (read-only fs, permission denied, ...) so callers can surface
682 a clean error to the user rather than an unhandled ``OSError``.
683 """

Calls

no outgoing calls