Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion extensions/EXTENSION-API-REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,25 @@ requires:
required: boolean # Optional, default: false

provides:
commands: # Required, at least one command
commands: # At least one of commands/templates/scripts/hooks/events required
Comment thread
mnriem marked this conversation as resolved.
- name: string # Required, pattern: ^speckit\.[a-z0-9-]+\.[a-z0-9-]+$
file: string # Required, relative path to command file
description: string # Required
aliases: [string] # Optional, same pattern as name; namespace must match extension.id and must not shadow core or installed extension commands

templates: # Optional, array of declared templates. Always resolve
# as "replace" -- 'strategy' is not an authorable field here.
- name: string # Required, pattern: ^[a-z0-9-]+$
file: string # Required, relative path to template file
description: string # Optional

scripts: # Optional, array of declared scripts. Always resolve
# as "replace" -- 'strategy' is not an authorable field here.
- name: string # Required, pattern: ^[a-z0-9-]+$
file: string # Required, relative path to script file
description: string # Optional
runtimes: [string] # Optional, subset of: bash, powershell, python

config: # Optional, array of config files
- name: string # Config file name
template: string # Template file path
Expand Down Expand Up @@ -111,6 +124,29 @@ defaults: # Optional, default configuration values
- **Examples**: `speckit.jira.specstoissues`, `speckit.linear.sync`
- **Invalid**: `jira.specstoissues`, `speckit.command`, `speckit.jira.CreateIssues`

#### `provides.templates[].name` / `provides.scripts[].name`

- **Type**: string
- **Pattern**: `^[a-z0-9-]+$`
- **Description**: Unlike commands, templates and scripts are not invoked by
name, so they use the same plain slug pattern as `extension.id` rather than
the namespaced command pattern.
- **Examples**: `myext-template`, `myext-collect`

#### `provides.templates[].strategy` / `provides.scripts[].strategy`

- Not an authorable field. Extension-contributed templates and scripts are
always resolved as `replace`; a manifest that includes a `strategy` key on
one of these entries is rejected with a `ValidationError`. Composable
strategies (`wrap`/`prepend`/`append`) are preset-only.

#### `provides.scripts[].runtimes`

- **Type**: array of strings
- **Values**: `bash`, `powershell`, `python`
- **Description**: Declares which runtimes the script supports. Purely
informational metadata — it is not used to select or invoke the script.

#### `hooks`

- **Type**: object
Expand Down Expand Up @@ -143,6 +179,8 @@ manifest.version # str: Version
manifest.description # str: Description
manifest.requires_speckit_version # str: Required spec-kit version
manifest.commands # List[Dict]: Command definitions
manifest.templates # List[Dict]: Declared template definitions
manifest.scripts # List[Dict]: Declared script definitions
manifest.hooks # Dict: Hook definitions
```

Expand Down
99 changes: 97 additions & 2 deletions src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@
)
EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$")

# Naming pattern for provides.templates / provides.scripts entries. Unlike
# commands, these are not namespaced (they aren't invoked via a command
# name), so they follow the same plain slug pattern as extension.id.
VALID_EXTENSION_ARTIFACT_NAME_PATTERN = re.compile(r"^[a-z0-9-]+$")

VALID_SCRIPT_RUNTIMES = frozenset({"bash", "powershell", "python"})

VALID_EFFECTS = frozenset({"read-only", "read-write"})

DEFAULT_HOOK_PRIORITY = 10
Expand Down Expand Up @@ -368,11 +375,17 @@ def _validate(self):
f"Invalid provides: expected a mapping, got {type(provides).__name__}"
)
commands = provides.get("commands", [])
templates = provides.get("templates", [])
scripts = provides.get("scripts", [])
hooks = self.data.get("hooks")
events = self.data.get("events")

if "commands" in provides and not isinstance(commands, list):
raise ValidationError("Invalid provides.commands: expected a list")
if "templates" in provides and not isinstance(templates, list):
raise ValidationError("Invalid provides.templates: expected a list")
if "scripts" in provides and not isinstance(scripts, list):
raise ValidationError("Invalid provides.scripts: expected a list")
if "hooks" in self.data and not isinstance(hooks, dict):
raise ValidationError("Invalid hooks: expected a mapping")
if "events" in self.data:
Expand All @@ -382,9 +395,17 @@ def _validate(self):
has_commands = bool(commands)
has_hooks = bool(hooks)
has_events = bool(events)
has_templates = bool(templates)
has_scripts = bool(scripts)

if not has_commands and not has_hooks and not has_events and not has_templates and not has_scripts:
raise ValidationError(
"Extension must provide at least one command, hook, or event "
"(or a declared template/script)"
)

if not has_commands and not has_hooks and not has_events:
raise ValidationError("Extension must provide at least one command, hook, or event")
self._validate_provided_artifacts(templates, section="templates", singular="template")
self._validate_provided_artifacts(scripts, section="scripts", singular="script")

# Validate hook values (if present).
# Each event is a single mapping or a list of mappings.
Expand Down Expand Up @@ -545,6 +566,70 @@ def _validate(self):
f"The extension author should update the manifest."
)

@staticmethod
def _validate_provided_artifacts(entries: List[Any], section: str, singular: str) -> None:
"""Validate provides.templates / provides.scripts entries.

Mirrors the shape/path-safety checks PresetManifest applies to its
non-command templates, minus 'type' (the section name already
distinguishes template vs script) and 'strategy' (extension-provided
artifacts are always 'replace' -- see the forced-replace resolver
behavior for extension layers in presets/__init__.py). A present
'strategy' key is rejected rather than silently ignored, so an author
who copies a preset-style entry gets a clear error instead of a
silently-dropped field.
"""
for entry in entries:
if not isinstance(entry, dict):
raise ValidationError(
f"Each entry in 'provides.{section}' must be a mapping"
)
if "name" not in entry or "file" not in entry:
raise ValidationError(f"{singular.capitalize()} missing 'name' or 'file'")

name = entry["name"]
if not isinstance(name, str):
raise ValidationError(
f"Invalid {singular} name: expected a string, got {type(name).__name__}"
)
if not VALID_EXTENSION_ARTIFACT_NAME_PATTERN.match(name):
raise ValidationError(
f"Invalid {singular} name '{name}': "
"must be lowercase alphanumeric with hyphens only"
)

file_value = entry["file"]
reason = relative_extension_path_violation(file_value)
if reason:
label = repr(file_value) if isinstance(file_value, str) else f"for {singular} '{name}'"
raise ValidationError(f"Invalid {singular} 'file' {label}: {reason}")

if "description" in entry and not isinstance(entry["description"], str):
raise ValidationError(
f"Invalid {singular} description for '{name}': expected a string"
)

if "strategy" in entry:
raise ValidationError(
f"Invalid {singular} entry '{name}': 'strategy' is not authorable for "
"extension-provided artifacts, which always use 'replace' semantics"
)

if section == "scripts" and "runtimes" in entry:
runtimes = entry["runtimes"]
if not isinstance(runtimes, list) or not all(
isinstance(r, str) for r in runtimes
):
raise ValidationError(
f"Invalid runtimes for script '{name}': expected a list of strings"
)
invalid = sorted(set(runtimes) - VALID_SCRIPT_RUNTIMES)
if invalid:
raise ValidationError(
f"Invalid runtimes {invalid} for script '{name}': "
f"must be one of {sorted(VALID_SCRIPT_RUNTIMES)}"
)

@staticmethod
def _try_correct_command_name(name: str, ext_id: str) -> Optional[str]:
"""Try to auto-correct a non-conforming command name to the required pattern.
Expand Down Expand Up @@ -615,6 +700,16 @@ def config(self) -> List[Dict[str, Any]]:
return []
return raw

@property
def templates(self) -> List[Dict[str, Any]]:
"""Get list of declared templates (provides.templates)."""
return self.data.get("provides", {}).get("templates", [])

@property
def scripts(self) -> List[Dict[str, Any]]:
"""Get list of declared scripts (provides.scripts)."""
return self.data.get("provides", {}).get("scripts", [])

@property
def hooks(self) -> Dict[str, Any]:
"""Get hook definitions."""
Expand Down
21 changes: 14 additions & 7 deletions src/specify_cli/presets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5426,18 +5426,25 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]:
continue
# Try convention-based lookup first
candidate = _find_in_subdirs(ext_dir)
# If not found and this is a command, check extension manifest
if candidate is None and template_type == "command":
# If not found, check the extension manifest for a declared
# command/template/script entry with this name.
if candidate is None and template_type in ("command", "template", "script"):
Comment thread
mnriem marked this conversation as resolved.
Outdated
ext_manifest_path = ext_dir / "extension.yml"
if ext_manifest_path.exists():
try:
from ..extensions import ExtensionManifest, ValidationError as ExtValidationError
ext_manifest = ExtensionManifest(ext_manifest_path)
for cmd in ext_manifest.commands:
if cmd.get("name") == template_name:
cmd_file = cmd.get("file")
if cmd_file:
c = ext_dir / cmd_file
if template_type == "command":
entries = ext_manifest.commands
elif template_type == "template":
entries = ext_manifest.templates
else:
entries = ext_manifest.scripts
Comment thread
mnriem marked this conversation as resolved.
Outdated
for entry in entries:
if entry.get("name") == template_name:
entry_file = entry.get("file")
if entry_file:
c = ext_dir / entry_file
if c.exists():
candidate = c
break
Expand Down
Loading