Load community-installed custom step types into STEP_REGISTRY. Scans ``.specify/workflows/steps/`` for installed step packages. Each valid package must contain ``step.yml`` (with a ``step.type_key`` field) and ``__init__.py`` (a ``StepBase`` subclass). Returns a list of type_keys t
(project_root: Path)
| 73 | |
| 74 | |
| 75 | def load_custom_steps(project_root: Path) -> list[str]: |
| 76 | """Load community-installed custom step types into STEP_REGISTRY. |
| 77 | |
| 78 | Scans ``.specify/workflows/steps/`` for installed step packages. |
| 79 | Each valid package must contain ``step.yml`` (with a ``step.type_key`` |
| 80 | field) and ``__init__.py`` (a ``StepBase`` subclass). |
| 81 | |
| 82 | Returns a list of type_keys that were successfully loaded. |
| 83 | Silently skips packages that fail to import or validate. |
| 84 | """ |
| 85 | import hashlib as _hashlib |
| 86 | import importlib.util as _importlib_util |
| 87 | import re as _re |
| 88 | import sys as _sys |
| 89 | |
| 90 | steps_dir = Path(project_root) / ".specify" / "workflows" / "steps" |
| 91 | |
| 92 | # Defense-in-depth: refuse to execute step code from a symlinked |
| 93 | # parent directory under .specify/workflows/steps, which could redirect |
| 94 | # the import outside the project root and bypass the install-time |
| 95 | # symlink guard. Check symlinks *before* is_dir() since the latter |
| 96 | # follows symlinks and would stat an external target. |
| 97 | _current = Path(project_root) |
| 98 | for _part in (".specify", "workflows", "steps"): |
| 99 | _current = _current / _part |
| 100 | if _current.is_symlink(): |
| 101 | return [] |
| 102 | |
| 103 | if not steps_dir.is_dir(): |
| 104 | return [] |
| 105 | |
| 106 | loaded: list[str] = [] |
| 107 | for step_dir in steps_dir.iterdir(): |
| 108 | # Check symlinks before is_dir() since the latter follows symlinks |
| 109 | # and would stat an external target through a symlinked directory. |
| 110 | if step_dir.is_symlink(): |
| 111 | continue |
| 112 | if not step_dir.is_dir(): |
| 113 | continue |
| 114 | step_yml = step_dir / "step.yml" |
| 115 | init_py = step_dir / "__init__.py" |
| 116 | if step_yml.is_symlink() or init_py.is_symlink(): |
| 117 | continue |
| 118 | if not step_yml.is_file() or not init_py.is_file(): |
| 119 | continue |
| 120 | |
| 121 | try: |
| 122 | import yaml as _yaml |
| 123 | |
| 124 | meta = _yaml.safe_load(step_yml.read_text(encoding="utf-8")) or {} |
| 125 | step_meta = meta.get("step", {}) |
| 126 | type_key = step_meta.get("type_key", "") |
| 127 | if not type_key: |
| 128 | continue |
| 129 | |
| 130 | # Skip if already registered (e.g. built-in or previously loaded) |
| 131 | if type_key in STEP_REGISTRY: |
| 132 | continue |