diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 6d7b7c1199..da1a5f4472 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -523,10 +523,11 @@ def _collect_manifest_command_names(manifest: ExtensionManifest) -> Dict[str, st """Collect command and alias names declared by a manifest. Performs install-time validation for extension-specific constraints: - - commands and aliases must use the canonical `speckit.{extension}.{command}` shape - - commands and aliases must use this extension's namespace + - primary commands must use the canonical `speckit.{extension}.{command}` shape + - primary commands must use this extension's namespace - command namespaces must not shadow core commands - duplicate command/alias names inside one manifest are rejected + - aliases are validated for type and uniqueness only (no pattern enforcement) Args: manifest: Parsed extension manifest @@ -563,23 +564,26 @@ def _collect_manifest_command_names(manifest: ExtensionManifest) -> Dict[str, st f"{kind.capitalize()} for command '{primary_name}' must be a string" ) - match = EXTENSION_COMMAND_NAME_PATTERN.match(name) - if match is None: - raise ValidationError( - f"Invalid {kind} '{name}': " - "must follow pattern 'speckit.{extension}.{command}'" - ) + # Enforce canonical pattern only for primary command names; + # aliases are free-form to preserve community extension compat. + if kind == "command": + match = EXTENSION_COMMAND_NAME_PATTERN.match(name) + if match is None: + raise ValidationError( + f"Invalid {kind} '{name}': " + "must follow pattern 'speckit.{extension}.{command}'" + ) - namespace = match.group(1) - if namespace != manifest.id: - raise ValidationError( - f"{kind.capitalize()} '{name}' must use extension namespace '{manifest.id}'" - ) + namespace = match.group(1) + if namespace != manifest.id: + raise ValidationError( + f"{kind.capitalize()} '{name}' must use extension namespace '{manifest.id}'" + ) - if namespace in CORE_COMMAND_NAMES: - raise ValidationError( - f"{kind.capitalize()} '{name}' conflicts with core command namespace '{namespace}'" - ) + if namespace in CORE_COMMAND_NAMES: + raise ValidationError( + f"{kind.capitalize()} '{name}' conflicts with core command namespace '{namespace}'" + ) if name in declared_names: raise ValidationError( diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 9d4df6a9a1..c5aed03dcf 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -686,8 +686,8 @@ def test_install_rejects_extension_id_in_core_namespace(self, temp_dir, project_ with pytest.raises(ValidationError, match="conflicts with core command namespace"): manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) - def test_install_rejects_alias_without_extension_namespace(self, temp_dir, project_dir): - """Install should reject legacy short aliases that can shadow core commands.""" + def test_install_accepts_short_alias(self, temp_dir, project_dir): + """Install should accept legacy short aliases for community extension compat.""" import yaml ext_dir = temp_dir / "alias-shortcut" @@ -718,8 +718,8 @@ def test_install_rejects_alias_without_extension_namespace(self, temp_dir, proje (ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody") manager = ExtensionManager(project_dir) - with pytest.raises(ValidationError, match="Invalid alias 'speckit.shortcut'"): - manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + # Should not raise — short aliases are allowed + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) def test_install_rejects_namespace_squatting(self, temp_dir, project_dir): """Install should reject commands and aliases outside the extension namespace."""