Skip to content
Prev Previous commit
fix: address sixth round of review feedback
- Stricter name validation in list_available(): templates must match
  ^[a-z0-9-]+$ (excludes README.md etc.), commands must match
  ^speckit\.[a-z0-9.-]+$, scripts must match ^[a-z0-9-]+$
- Script deduplication in list_available() and ExtensionResolver
  .list_templates() now prefers .sh over .ps1 when both exist,
  matching resolve() extension order
- ExtensionResolver.list_templates() adds same name validation
  and script dedup logic
- Type checks for extension.id/name/version/description (must be
  strings) and requires.speckit_version (must be string)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
  • Loading branch information
iamaeroplane and claude committed Mar 29, 2026
commit dca335f93da0e6ce5e5442848d90df97fa4e5a86
39 changes: 31 additions & 8 deletions src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ def _validate(self):
for field in ["id", "name", "version", "description"]:
if field not in ext:
raise ValidationError(f"Missing extension.{field}")
if not isinstance(ext[field], str):
raise ValidationError(f"extension.{field} must be a string")

# Validate extension ID format
if not re.match(r'^[a-z0-9-]+$', ext["id"]):
Expand All @@ -141,6 +143,8 @@ def _validate(self):
raise ValidationError("'requires' must be a mapping")
if "speckit_version" not in requires:
raise ValidationError("Missing requires.speckit_version")
Comment thread
mbachorik marked this conversation as resolved.
if not isinstance(requires["speckit_version"], str):
raise ValidationError("requires.speckit_version must be a string")

# Validate provides section
provides = self.data["provides"]
Expand Down Expand Up @@ -1002,6 +1006,7 @@ def list_templates(
List of dicts with 'name', 'path', and 'source' keys.
"""
subdirs, exts = self._type_config(template_type)
name_re = self._name_re_for_type(template_type)
results: List[Dict[str, str]] = []
seen: set[str] = set()

Expand All @@ -1020,19 +1025,37 @@ def list_templates(
scan_dir = ext_dir / subdir if subdir else ext_dir
if not scan_dir.is_dir():
continue
for f in sorted(scan_dir.iterdir()):
if f.is_file() and f.suffix in exts:
name = f.stem

if template_type == "script":
# Prefer .sh over .ps1 when both exist (matches resolve order)
candidates: Dict[str, Path] = {}
for f in sorted(scan_dir.iterdir()):
if f.is_file() and f.suffix in exts and name_re.match(f.stem):
existing = candidates.get(f.stem)
if existing is None or exts.index(f.suffix) < exts.index(existing.suffix):
candidates[f.stem] = f
for name, f in sorted(candidates.items()):
if name not in seen:
seen.add(name)
results.append({
"name": name,
"path": str(f),
"source": source_label,
})
results.append({"name": name, "path": str(f), "source": source_label})
else:
for f in sorted(scan_dir.iterdir()):
if f.is_file() and f.suffix in exts:
name = f.stem
if name not in seen and name_re.match(name):
seen.add(name)
results.append({"name": name, "path": str(f), "source": source_label})

return results

@staticmethod
def _name_re_for_type(template_type: str) -> re.Pattern:
"""Return compiled regex for valid names of the given template type."""
if template_type == "command":
return re.compile(r'^speckit\.[a-z0-9.-]+$')
# template and script both use lowercase-alphanumeric-hyphen
return re.compile(r'^[a-z0-9-]+$')

@staticmethod
def _type_config(template_type: str) -> tuple:
"""Return (subdirs, file_extensions) for a template type.
Expand Down
60 changes: 38 additions & 22 deletions src/specify_cli/presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -1704,38 +1704,54 @@ def list_available(
else: # script
subdirs = ["scripts"]

_template_name_re = re.compile(r'^[a-z0-9-]+$')
_command_name_re = re.compile(r'^speckit\.[a-z0-9.-]+$')

def _name_matches_type(name: str) -> bool:
"""Check if a file name matches the expected pattern for the template type.
"""Check if a file name matches the expected naming rules for the template type.

Commands use dot notation (e.g. speckit.specify), templates use
hyphens only (e.g. spec-template). This prevents the shared
overrides directory from leaking commands into template listings
or vice versa. Scripts live in their own subdirectory so no
filtering is needed.
Commands must start with speckit. and use dot notation.
Templates/scripts must be lowercase alphanumeric with hyphens only.
This prevents README.md, CHANGELOG.md, etc. from appearing as
templates, and keeps the shared overrides directory clean.
"""
if template_type == "command":
return "." in name
return _command_name_re.match(name) is not None
if template_type == "template":
return "." not in name
return True
return "." not in name and _template_name_re.match(name) is not None
# script
return _template_name_re.match(name) is not None

def _collect(directory: Path, source: str):
"""Collect template files from a directory."""
if not directory.is_dir():
return
for f in sorted(directory.iterdir()):
if f.is_file() and f.suffix in exts:
name = f.stem
if name in seen:
continue
if not _name_matches_type(name):
continue
seen.add(name)
results.append({
"name": name,
"path": str(f),
"source": source,
})
if template_type == "script":
# For scripts, both .sh and .ps1 may exist for the same stem.
# Pick the one matching resolution order (exts list order).
candidates: dict[str, Path] = {}
for f in sorted(directory.iterdir()):
if f.is_file() and f.suffix in exts:
name = f.stem
if not _name_matches_type(name):
continue
existing = candidates.get(name)
if existing is None or exts.index(f.suffix) < exts.index(existing.suffix):
candidates[name] = f
for name, f in sorted(candidates.items()):
if name not in seen:
seen.add(name)
results.append({"name": name, "path": str(f), "source": source})
else:
for f in sorted(directory.iterdir()):
if f.is_file() and f.suffix in exts:
name = f.stem
if name in seen:
continue
if not _name_matches_type(name):
continue
seen.add(name)
results.append({"name": name, "path": str(f), "source": source})

# Priority 1: Project-local overrides
if template_type == "script":
Expand Down
Loading