From 879ddd45467106fc131df91d4e524e8c115d9ef6 Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Mon, 30 Mar 2026 21:51:03 +0200 Subject: [PATCH 01/10] docs: warn about unofficial PyPI packages and recommend version verification (#1982) Clarify that only packages from github/spec-kit are official, and add `specify version` as a post-install verification step to help users catch accidental installation of an unrelated package with the same name. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 10 +++++++++- docs/installation.md | 10 ++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8b2afdee66..c9a0bc198f 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ Spec-Driven Development **flips the script** on traditional software development Choose your preferred installation method: +> **Important:** The only official, maintained packages for Spec Kit are published from this GitHub repository. Any packages with the same name on PyPI are **not** affiliated with this project and are not maintained by the Spec Kit maintainers. Always install directly from GitHub as shown below. + #### Option 1: Persistent Installation (Recommended) Install once and use everywhere. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest): @@ -62,7 +64,13 @@ uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX uv tool install specify-cli --from git+https://github.com/github/spec-kit.git ``` -Then use the tool directly: +Then verify the correct version is installed: + +```bash +specify version +``` + +And use the tool directly: ```bash # Create new project diff --git a/docs/installation.md b/docs/installation.md index 5d560b6e33..25a1074c80 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -10,6 +10,8 @@ ## Installation +> **Important:** The only official, maintained packages for Spec Kit are published from the [github/spec-kit](https://github.com/github/spec-kit) GitHub repository. Any packages with the same name available on PyPI (e.g. `specify-cli` on pypi.org) are **not** affiliated with this project and are not maintained by the Spec Kit maintainers. Always install directly from GitHub as shown below. + ### Initialize a New Project The easiest way to get started is to initialize a new project. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest): @@ -69,6 +71,14 @@ uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init Date: Mon, 30 Mar 2026 22:25:08 +0200 Subject: [PATCH 02/10] fix(extensions): auto-correct legacy command names instead of hard-failing (#2017) Community extensions that predate the strict naming requirement use two common legacy formats ('speckit.command' and 'extension.command'). Instead of rejecting them outright, auto-correct to the required 'speckit.{extension}.{command}' pattern and emit a compatibility warning so authors know they need to update their manifest. Names that cannot be safely corrected (e.g. single-segment names) still raise ValidationError. Co-Authored-By: Claude Sonnet 4.6 --- src/specify_cli/__init__.py | 4 +++ src/specify_cli/extensions.py | 40 +++++++++++++++++++++++++---- tests/test_extensions.py | 48 ++++++++++++++++++++++++++++++++++- 3 files changed, 86 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index f528535a61..1e55f191f0 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -3653,6 +3653,10 @@ def extension_add( console.print("\n[green]✓[/green] Extension installed successfully!") console.print(f"\n[bold]{manifest.name}[/bold] (v{manifest.version})") console.print(f" {manifest.description}") + + for warning in manifest.warnings: + console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {warning}") + console.print("\n[bold cyan]Provided commands:[/bold cyan]") for cmd in manifest.commands: console.print(f" • {cmd['name']} - {cmd.get('description', '')}") diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index b898c65f2a..8fcc35560a 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -130,6 +130,7 @@ def __init__(self, manifest_path: Path): ValidationError: If manifest is invalid """ self.path = manifest_path + self.warnings: List[str] = [] self.data = self._load_yaml(manifest_path) self._validate() @@ -192,11 +193,40 @@ def _validate(self): raise ValidationError("Command missing 'name' or 'file'") # Validate command name format - if EXTENSION_COMMAND_NAME_PATTERN.match(cmd["name"]) is None: - raise ValidationError( - f"Invalid command name '{cmd['name']}': " - "must follow pattern 'speckit.{extension}.{command}'" - ) + if not EXTENSION_COMMAND_NAME_PATTERN.match(cmd["name"]): + corrected = self._try_correct_command_name(cmd["name"], ext["id"]) + if corrected: + self.warnings.append( + f"Command name '{cmd['name']}' does not follow the required pattern " + f"'speckit.{{extension}}.{{command}}'. Registering as '{corrected}'. " + f"The extension author should update the manifest to use this name." + ) + cmd["name"] = corrected + else: + raise ValidationError( + f"Invalid command name '{cmd['name']}': " + "must follow pattern 'speckit.{extension}.{command}'" + ) + + @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. + + Handles the two most common legacy formats used by community extensions: + - 'speckit.command' → 'speckit.{ext_id}.command' + - 'extension.command' → 'speckit.extension.command' + + Returns the corrected name, or None if no safe correction is possible. + """ + parts = name.split('.') + if len(parts) == 2: + if parts[0] == 'speckit': + candidate = f"speckit.{ext_id}.{parts[1]}" + else: + candidate = f"speckit.{parts[0]}.{parts[1]}" + if EXTENSION_COMMAND_NAME_PATTERN.match(candidate): + return candidate + return None @property def id(self) -> str: diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 64b38547d7..aca3cf345d 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -241,7 +241,7 @@ def test_invalid_version(self, temp_dir, valid_manifest_data): ExtensionManifest(manifest_path) def test_invalid_command_name(self, temp_dir, valid_manifest_data): - """Test manifest with invalid command name format.""" + """Test manifest with command name that cannot be auto-corrected raises ValidationError.""" import yaml valid_manifest_data["provides"]["commands"][0]["name"] = "invalid-name" @@ -253,6 +253,52 @@ def test_invalid_command_name(self, temp_dir, valid_manifest_data): with pytest.raises(ValidationError, match="Invalid command name"): ExtensionManifest(manifest_path) + def test_command_name_autocorrect_speckit_prefix(self, temp_dir, valid_manifest_data): + """Test that 'speckit.command' is auto-corrected to 'speckit.{ext_id}.command'.""" + import yaml + + valid_manifest_data["provides"]["commands"][0]["name"] = "speckit.hello" + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + + assert manifest.commands[0]["name"] == "speckit.test-ext.hello" + assert len(manifest.warnings) == 1 + assert "speckit.hello" in manifest.warnings[0] + assert "speckit.test-ext.hello" in manifest.warnings[0] + + def test_command_name_autocorrect_no_speckit_prefix(self, temp_dir, valid_manifest_data): + """Test that 'extension.command' is auto-corrected to 'speckit.extension.command'.""" + import yaml + + valid_manifest_data["provides"]["commands"][0]["name"] = "docguard.guard" + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + + assert manifest.commands[0]["name"] == "speckit.docguard.guard" + assert len(manifest.warnings) == 1 + assert "docguard.guard" in manifest.warnings[0] + assert "speckit.docguard.guard" in manifest.warnings[0] + + def test_valid_command_name_has_no_warnings(self, temp_dir, valid_manifest_data): + """Test that a correctly-named command produces no warnings.""" + import yaml + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + + assert manifest.warnings == [] + def test_no_commands(self, temp_dir, valid_manifest_data): """Test manifest with no commands provided.""" import yaml From 44d19961656d6660d0d58bec517020ffef6b023e Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Mon, 30 Mar 2026 22:41:19 +0200 Subject: [PATCH 03/10] fix(tests): isolate preset catalog search test from community catalog network calls test_search_with_cached_data asserted exactly 2 results but was getting 4 because _get_merged_packs() queries the full built-in catalog stack (default + community). The community catalog had no local cache and hit the network, returning real presets. Writing a project-level preset-catalogs.yml that pins the test to the default URL only makes the count assertions deterministic. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_presets.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_presets.py b/tests/test_presets.py index 1b2704c57f..fe8bef6f2b 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1179,6 +1179,16 @@ def test_search_with_cached_data(self, project_dir, monkeypatch): catalog = PresetCatalog(project_dir) catalog.cache_dir.mkdir(parents=True, exist_ok=True) + # Restrict to default catalog only — prevents community catalog network calls + # which would add extra results and make the count assertions flaky. + (project_dir / ".specify" / "preset-catalogs.yml").write_text( + f"catalogs:\n" + f" - url: \"{PresetCatalog.DEFAULT_CATALOG_URL}\"\n" + f" name: default\n" + f" priority: 1\n" + f" install_allowed: true\n" + ) + catalog_data = { "schema_version": "1.0", "presets": { From fc9945242f03b685b3b56d742ed11c3507492fb3 Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Mon, 30 Mar 2026 23:47:21 +0200 Subject: [PATCH 04/10] fix(extensions): extend auto-correction to aliases (#2017) The upstream #1994 added alias validation in _collect_manifest_command_names, which also rejected legacy 2-part alias names (e.g. 'speckit.verify'). Extend the same auto-correction logic from _validate() to cover aliases, so both 'speckit.command' and 'extension.command' alias formats are corrected to 'speckit.{ext_id}.{command}' with a compatibility warning instead of hard-failing. Co-Authored-By: Claude Sonnet 4.6 --- src/specify_cli/extensions.py | 18 ++++++++++++++++++ tests/test_extensions.py | 29 +++++++++++++++++++++++++---- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 8fcc35560a..27c1a687e7 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -208,6 +208,24 @@ def _validate(self): "must follow pattern 'speckit.{extension}.{command}'" ) + # Validate and auto-correct alias name formats + aliases = cmd.get("aliases") or [] + for i, alias in enumerate(aliases): + if not EXTENSION_COMMAND_NAME_PATTERN.match(alias): + corrected = self._try_correct_command_name(alias, ext["id"]) + if corrected: + self.warnings.append( + f"Alias '{alias}' does not follow the required pattern " + f"'speckit.{{extension}}.{{command}}'. Registering as '{corrected}'. " + f"The extension author should update the manifest to use this name." + ) + aliases[i] = corrected + else: + raise ValidationError( + f"Invalid alias '{alias}': " + "must follow pattern 'speckit.{extension}.{command}'" + ) + @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. diff --git a/tests/test_extensions.py b/tests/test_extensions.py index aca3cf345d..afd97cc28c 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -287,6 +287,23 @@ def test_command_name_autocorrect_no_speckit_prefix(self, temp_dir, valid_manife assert "docguard.guard" in manifest.warnings[0] assert "speckit.docguard.guard" in manifest.warnings[0] + def test_alias_autocorrect_speckit_prefix(self, temp_dir, valid_manifest_data): + """Test that a legacy 'speckit.command' alias is auto-corrected.""" + import yaml + + valid_manifest_data["provides"]["commands"][0]["aliases"] = ["speckit.hello"] + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + + assert manifest.commands[0]["aliases"] == ["speckit.test-ext.hello"] + assert len(manifest.warnings) == 1 + assert "speckit.hello" in manifest.warnings[0] + assert "speckit.test-ext.hello" in manifest.warnings[0] + def test_valid_command_name_has_no_warnings(self, temp_dir, valid_manifest_data): """Test that a correctly-named command produces no warnings.""" import yaml @@ -681,8 +698,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_autocorrects_alias_without_extension_namespace(self, temp_dir, project_dir): + """Legacy short aliases are auto-corrected to 'speckit.{ext_id}.{cmd}' with a warning.""" import yaml ext_dir = temp_dir / "alias-shortcut" @@ -713,8 +730,12 @@ 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) + manifest = manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + assert manifest.commands[0]["aliases"] == ["speckit.alias-shortcut.shortcut"] + assert len(manifest.warnings) == 1 + assert "speckit.shortcut" in manifest.warnings[0] + assert "speckit.alias-shortcut.shortcut" in manifest.warnings[0] def test_install_rejects_namespace_squatting(self, temp_dir, project_dir): """Install should reject commands and aliases outside the extension namespace.""" From 16f03ff2d4fa0e5f0ba7b117f0171e891a5fc0e0 Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Tue, 31 Mar 2026 21:31:16 +0200 Subject: [PATCH 05/10] fix(extensions): address PR review feedback (#2017) - _try_correct_command_name: only correct 'X.Y' to 'speckit.ext_id.Y' when X matches ext_id, preventing misleading warnings followed by install failure due to namespace mismatch - _validate: add aliases type/string guards matching _collect_manifest _command_names defensive checks - _validate: track command renames and rewrite any hook.*.command references that pointed at a renamed command, emitting a warning - test: fix test_command_name_autocorrect_no_speckit_prefix to use ext_id matching the legacy namespace; add namespace-mismatch test - test: replace redundant preset-catalogs.yml isolation with monkeypatch.delenv("SPECKIT_PRESET_CATALOG_URL") so the env var cannot bypass catalog restriction in CI environments Co-Authored-By: Claude Sonnet 4.6 --- src/specify_cli/extensions.py | 46 +++++++++++++++++++++++++++-------- tests/test_extensions.py | 20 +++++++++++++-- tests/test_presets.py | 13 +--------- 3 files changed, 55 insertions(+), 24 deletions(-) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 27c1a687e7..16f596d804 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -187,7 +187,8 @@ def _validate(self): if "commands" not in provides or not provides["commands"]: raise ValidationError("Extension must provide at least one command") - # Validate commands + # Validate commands; track renames so hook references can be rewritten. + rename_map: Dict[str, str] = {} for cmd in provides["commands"]: if "name" not in cmd or "file" not in cmd: raise ValidationError("Command missing 'name' or 'file'") @@ -201,6 +202,7 @@ def _validate(self): f"'speckit.{{extension}}.{{command}}'. Registering as '{corrected}'. " f"The extension author should update the manifest to use this name." ) + rename_map[cmd["name"]] = corrected cmd["name"] = corrected else: raise ValidationError( @@ -209,8 +211,18 @@ def _validate(self): ) # Validate and auto-correct alias name formats - aliases = cmd.get("aliases") or [] + aliases = cmd.get("aliases") + if aliases is None: + aliases = [] + if not isinstance(aliases, list): + raise ValidationError( + f"Aliases for command '{cmd['name']}' must be a list" + ) for i, alias in enumerate(aliases): + if not isinstance(alias, str): + raise ValidationError( + f"Aliases for command '{cmd['name']}' must be strings" + ) if not EXTENSION_COMMAND_NAME_PATTERN.match(alias): corrected = self._try_correct_command_name(alias, ext["id"]) if corrected: @@ -219,6 +231,7 @@ def _validate(self): f"'speckit.{{extension}}.{{command}}'. Registering as '{corrected}'. " f"The extension author should update the manifest to use this name." ) + rename_map[alias] = corrected aliases[i] = corrected else: raise ValidationError( @@ -226,24 +239,37 @@ def _validate(self): "must follow pattern 'speckit.{extension}.{command}'" ) + # Rewrite any hook command references that pointed at a renamed command. + for hook_name, hook_data in self.data.get("hooks", {}).items(): + if isinstance(hook_data, dict) and hook_data.get("command") in rename_map: + old_ref = hook_data["command"] + hook_data["command"] = rename_map[old_ref] + self.warnings.append( + f"Hook '{hook_name}' referenced renamed command '{old_ref}'; " + f"updated to '{rename_map[old_ref]}'. " + f"The extension author should update the manifest." + ) + @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. - Handles the two most common legacy formats used by community extensions: - - 'speckit.command' → 'speckit.{ext_id}.command' - - 'extension.command' → 'speckit.extension.command' + Handles the two legacy formats used by community extensions: + - 'speckit.command' → 'speckit.{ext_id}.command' + - '{ext_id}.command' → 'speckit.{ext_id}.command' + + The 'X.Y' form is only corrected when X matches ext_id to ensure the + result passes the install-time namespace check. Any other prefix is + uncorrectable and will produce a ValidationError at the call site. Returns the corrected name, or None if no safe correction is possible. """ parts = name.split('.') if len(parts) == 2: - if parts[0] == 'speckit': + if parts[0] == 'speckit' or parts[0] == ext_id: candidate = f"speckit.{ext_id}.{parts[1]}" - else: - candidate = f"speckit.{parts[0]}.{parts[1]}" - if EXTENSION_COMMAND_NAME_PATTERN.match(candidate): - return candidate + if EXTENSION_COMMAND_NAME_PATTERN.match(candidate): + return candidate return None @property diff --git a/tests/test_extensions.py b/tests/test_extensions.py index afd97cc28c..e612958c09 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -270,10 +270,12 @@ def test_command_name_autocorrect_speckit_prefix(self, temp_dir, valid_manifest_ assert "speckit.hello" in manifest.warnings[0] assert "speckit.test-ext.hello" in manifest.warnings[0] - def test_command_name_autocorrect_no_speckit_prefix(self, temp_dir, valid_manifest_data): - """Test that 'extension.command' is auto-corrected to 'speckit.extension.command'.""" + def test_command_name_autocorrect_matching_ext_id_prefix(self, temp_dir, valid_manifest_data): + """Test that '{ext_id}.command' is auto-corrected to 'speckit.{ext_id}.command'.""" import yaml + # Set ext_id to match the legacy namespace so correction is valid + valid_manifest_data["extension"]["id"] = "docguard" valid_manifest_data["provides"]["commands"][0]["name"] = "docguard.guard" manifest_path = temp_dir / "extension.yml" @@ -287,6 +289,20 @@ def test_command_name_autocorrect_no_speckit_prefix(self, temp_dir, valid_manife assert "docguard.guard" in manifest.warnings[0] assert "speckit.docguard.guard" in manifest.warnings[0] + def test_command_name_mismatched_namespace_not_corrected(self, temp_dir, valid_manifest_data): + """Test that 'X.command' is NOT corrected when X doesn't match ext_id.""" + import yaml + + # ext_id is "test-ext" but command uses a different namespace + valid_manifest_data["provides"]["commands"][0]["name"] = "docguard.guard" + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match="Invalid command name"): + ExtensionManifest(manifest_path) + def test_alias_autocorrect_speckit_prefix(self, temp_dir, valid_manifest_data): """Test that a legacy 'speckit.command' alias is auto-corrected.""" import yaml diff --git a/tests/test_presets.py b/tests/test_presets.py index fe8bef6f2b..2f8a24eef9 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1174,21 +1174,10 @@ def test_search_with_cached_data(self, project_dir, monkeypatch): """Test search with cached catalog data.""" from unittest.mock import patch - # Only use the default catalog to prevent fetching the community catalog from the network - monkeypatch.setenv("SPECKIT_PRESET_CATALOG_URL", PresetCatalog.DEFAULT_CATALOG_URL) + monkeypatch.delenv("SPECKIT_PRESET_CATALOG_URL", raising=False) catalog = PresetCatalog(project_dir) catalog.cache_dir.mkdir(parents=True, exist_ok=True) - # Restrict to default catalog only — prevents community catalog network calls - # which would add extra results and make the count assertions flaky. - (project_dir / ".specify" / "preset-catalogs.yml").write_text( - f"catalogs:\n" - f" - url: \"{PresetCatalog.DEFAULT_CATALOG_URL}\"\n" - f" name: default\n" - f" priority: 1\n" - f" install_allowed: true\n" - ) - catalog_data = { "schema_version": "1.0", "presets": { From d643d0faacf67ec8dc9a25603ba4185940db55cf Mon Sep 17 00:00:00 2001 From: Michal Bachorik Date: Mon, 6 Apr 2026 12:04:50 +0200 Subject: [PATCH 06/10] Update docs/installation.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/installation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation.md b/docs/installation.md index 25a1074c80..ed253902af 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -10,7 +10,7 @@ ## Installation -> **Important:** The only official, maintained packages for Spec Kit are published from the [github/spec-kit](https://github.com/github/spec-kit) GitHub repository. Any packages with the same name available on PyPI (e.g. `specify-cli` on pypi.org) are **not** affiliated with this project and are not maintained by the Spec Kit maintainers. Always install directly from GitHub as shown below. +> **Important:** The only official, maintained packages for Spec Kit come from the [github/spec-kit](https://github.com/github/spec-kit) GitHub repository. Any packages with the same name available on PyPI (e.g. `specify-cli` on pypi.org) are **not** affiliated with this project and are not maintained by the Spec Kit maintainers. For normal installs, use the GitHub-based commands shown below. For offline or air-gapped environments, locally built wheels created from this repository are also valid. ### Initialize a New Project From 61984d4d8dd5ef71c1edaeb36edd2f40e65cd620 Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Mon, 6 Apr 2026 12:52:18 +0200 Subject: [PATCH 07/10] fix(extensions): warn when hook command refs are silently canonicalized; fix grammar - Hook rewrites (alias-form or rename-map) now always emit a warning so extension authors know to update their manifests. Previously only rename-map rewrites produced a warning; pure alias-form lifts were silent. - Pluralize "command/commands" in the uninstall confirmation message so single-command extensions no longer print "1 commands". Co-Authored-By: Claude Sonnet 4.6 --- src/specify_cli/__init__.py | 2 +- src/specify_cli/extensions.py | 26 ++++++++++++++++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 1e55f191f0..444544811b 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -3718,7 +3718,7 @@ def extension_remove( # Confirm removal if not force: console.print("\n[yellow]⚠ This will remove:[/yellow]") - console.print(f" • {cmd_count} commands from AI agent") + console.print(f" • {cmd_count} command{'s' if cmd_count != 1 else ''} from AI agent") if skill_count: console.print(f" • {skill_count} agent skill(s)") console.print(f" • Extension directory: .specify/extensions/{extension_id}/") diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 16f596d804..1ec711214b 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -239,14 +239,28 @@ def _validate(self): "must follow pattern 'speckit.{extension}.{command}'" ) - # Rewrite any hook command references that pointed at a renamed command. + # Rewrite any hook command references that pointed at a renamed command or + # an alias-form ref (ext.cmd → speckit.ext.cmd). Always emit a warning when + # the reference is changed so extension authors know to update the manifest. for hook_name, hook_data in self.data.get("hooks", {}).items(): - if isinstance(hook_data, dict) and hook_data.get("command") in rename_map: - old_ref = hook_data["command"] - hook_data["command"] = rename_map[old_ref] + if not isinstance(hook_data, dict): + continue + command_ref = hook_data.get("command") + if not isinstance(command_ref, str): + continue + # Step 1: apply any rename from the auto-correction pass. + after_rename = rename_map.get(command_ref, command_ref) + # Step 2: lift alias-form '{ext_id}.cmd' to canonical 'speckit.{ext_id}.cmd'. + parts = after_rename.split(".") + if len(parts) == 2 and parts[0] == ext["id"]: + final_ref = f"speckit.{ext['id']}.{parts[1]}" + else: + final_ref = after_rename + if final_ref != command_ref: + hook_data["command"] = final_ref self.warnings.append( - f"Hook '{hook_name}' referenced renamed command '{old_ref}'; " - f"updated to '{rename_map[old_ref]}'. " + f"Hook '{hook_name}' referenced command '{command_ref}'; " + f"updated to canonical form '{final_ref}'. " f"The extension author should update the manifest." ) From ee106d15492ddf0b7d3ac74ff7da9ecb183704ba Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Mon, 6 Apr 2026 13:06:29 +0200 Subject: [PATCH 08/10] fix(extensions): raise ValidationError for non-dict hook entries Silently skipping non-dict hook entries left them in manifest.hooks, causing HookExecutor.register_hooks() to crash with AttributeError when it called hook_config.get() on a non-mapping value. Also updates PR description to accurately reflect the implementation (no separate _try_correct_alias_name helper; aliases use the same _try_correct_command_name path). Co-Authored-By: Claude Sonnet 4.6 --- src/specify_cli/extensions.py | 4 +++- tests/test_extensions.py | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 1ec711214b..17ba445790 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -244,7 +244,9 @@ def _validate(self): # the reference is changed so extension authors know to update the manifest. for hook_name, hook_data in self.data.get("hooks", {}).items(): if not isinstance(hook_data, dict): - continue + raise ValidationError( + f"Hook '{hook_name}' must be a mapping, got {type(hook_data).__name__}" + ) command_ref = hook_data.get("command") if not isinstance(command_ref, str): continue diff --git a/tests/test_extensions.py b/tests/test_extensions.py index e612958c09..5b8e175710 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -345,6 +345,19 @@ def test_no_commands(self, temp_dir, valid_manifest_data): with pytest.raises(ValidationError, match="must provide at least one command"): ExtensionManifest(manifest_path) + def test_non_dict_hook_entry_raises_validation_error(self, temp_dir, valid_manifest_data): + """Non-mapping hook entries must raise ValidationError, not silently skip.""" + import yaml + + valid_manifest_data["hooks"]["after_tasks"] = "speckit.test-ext.hello" + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match="Hook 'after_tasks' must be a mapping"): + ExtensionManifest(manifest_path) + def test_manifest_hash(self, extension_dir): """Test manifest hash calculation.""" manifest_path = extension_dir / "extension.yml" From bc3edb105900b7a796e05007d5458b6069906d93 Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Mon, 6 Apr 2026 14:20:46 +0200 Subject: [PATCH 09/10] fix(extensions): derive remove cmd_count from registry, fix wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously cmd_count used len(ext_manifest.commands) which only counted primary commands and missed aliases. The registry's registered_commands already tracks every command name (primaries + aliases) per agent, so max(len(v) for v in registered_commands.values()) gives the correct total. Also changes "from AI agent" → "across AI agents" since remove() unregisters commands from all detected agents. Co-Authored-By: Claude Sonnet 4.6 --- src/specify_cli/__init__.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 444544811b..f3f575b109 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -3710,15 +3710,24 @@ def extension_remove( # Get extension info for command and skill counts ext_manifest = manager.get_extension(extension_id) - cmd_count = len(ext_manifest.commands) if ext_manifest else 0 reg_meta = manager.registry.get(extension_id) + # Derive cmd_count from the registry's registered_commands (includes aliases, + # covers all agents) rather than from the manifest (primary commands only). + registered_commands = reg_meta.get("registered_commands", {}) if isinstance(reg_meta, dict) else {} + if registered_commands and isinstance(registered_commands, dict): + cmd_count = max( + (len(v) for v in registered_commands.values() if isinstance(v, list)), + default=0, + ) + else: + cmd_count = len(ext_manifest.commands) if ext_manifest else 0 raw_skills = reg_meta.get("registered_skills") if reg_meta else None skill_count = len(raw_skills) if isinstance(raw_skills, list) else 0 # Confirm removal if not force: console.print("\n[yellow]⚠ This will remove:[/yellow]") - console.print(f" • {cmd_count} command{'s' if cmd_count != 1 else ''} from AI agent") + console.print(f" • {cmd_count} command{'s' if cmd_count != 1 else ''} across AI agents") if skill_count: console.print(f" • {skill_count} agent skill(s)") console.print(f" • Extension directory: .specify/extensions/{extension_id}/") From b7d22da1899d5162163cd1ddb6eb2cc3a018502b Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Mon, 6 Apr 2026 14:33:12 +0200 Subject: [PATCH 10/10] fix(extensions): distinguish missing vs empty registered_commands in remove prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Using get() without a default lets us tell apart: - key missing (legacy registry entry) → fall back to manifest count - key present but empty dict (installed with no agent dirs) → show 0 Previously the truthiness check `if registered_commands and ...` treated both cases the same, so an empty dict fell back to len(manifest.commands) and overcounted commands that would actually be removed. Co-Authored-By: Claude Sonnet 4.6 --- src/specify_cli/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index f3f575b109..654e299ab8 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -3713,8 +3713,10 @@ def extension_remove( reg_meta = manager.registry.get(extension_id) # Derive cmd_count from the registry's registered_commands (includes aliases, # covers all agents) rather than from the manifest (primary commands only). - registered_commands = reg_meta.get("registered_commands", {}) if isinstance(reg_meta, dict) else {} - if registered_commands and isinstance(registered_commands, dict): + # Use get() without a default so we can distinguish "key missing" (fall back + # to manifest) from "key present but empty dict" (zero commands registered). + registered_commands = reg_meta.get("registered_commands") if isinstance(reg_meta, dict) else None + if isinstance(registered_commands, dict): cmd_count = max( (len(v) for v in registered_commands.values() if isinstance(v, list)), default=0,