From 879ddd45467106fc131df91d4e524e8c115d9ef6 Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Mon, 30 Mar 2026 21:51:03 +0200 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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, From b137f44bbda1bb102137f7bbfe636a6882c3a9f1 Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Fri, 10 Apr 2026 15:26:53 +0200 Subject: [PATCH 11/14] fix(extensions): clarify removal prompt wording to 'per agent' 'across AI agents' implied a total count, but cmd_count uses max() across agents (per-agent count). Using sum() would double-count since users think in logical commands, not per-agent files. 'per agent' accurately describes what the number represents. Co-Authored-By: Claude Sonnet 4.6 --- src/specify_cli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 654e299ab8..056498340d 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -3729,7 +3729,7 @@ def extension_remove( # 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 ''} across AI agents") + console.print(f" • {cmd_count} command{'s' if cmd_count != 1 else ''} per agent") if skill_count: console.print(f" • {skill_count} agent skill(s)") console.print(f" • Extension directory: .specify/extensions/{extension_id}/") From 8de8cdfb4ffab48aff1280a144eab2e31ff8e0cc Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Sat, 11 Apr 2026 06:09:06 +0200 Subject: [PATCH 12/14] =?UTF-8?q?fix(extensions):=20clarify=20cmd=5Fcount?= =?UTF-8?q?=20comment=20=E2=80=94=20per-agent=20max,=20not=20total?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment said 'covers all agents' implying a total, but cmd_count uses max() across agents (per-agent count). Updated comment to explain the max() choice and why sum() would double-count. 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 e013cb920b..4177aa2154 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -3190,8 +3190,10 @@ def extension_remove( # Get extension info for command and skill counts ext_manifest = manager.get_extension(extension_id) 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). + # Derive cmd_count from the registry's registered_commands (includes aliases) + # rather than from the manifest (primary commands only). Use max() across + # agents to get the per-agent count; sum() would double-count since users + # think in logical commands, not per-agent file counts. # 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 From dfb88bb010d9abfb0e723ffca3179e35eaa443b2 Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Sat, 11 Apr 2026 07:28:18 +0200 Subject: [PATCH 13/14] test(extensions): add CLI tests for remove confirmation pluralization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds TestExtensionRemoveCLI with two CliRunner tests: - singular: 1 registered command → '1 command per agent' - plural: 2 registered commands → '2 commands per agent' These prevent regressions on the cmd_count pluralization logic and the 'per agent' wording introduced in this PR. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_extensions.py | 55 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 3b85fe0292..dc274903ba 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -3833,3 +3833,58 @@ def test_hook_message_falls_back_when_invocation_is_empty(self, project_dir): assert "Executing: `/`" in message assert "EXECUTE_COMMAND: " in message assert "EXECUTE_COMMAND_INVOCATION: /" in message + + +class TestExtensionRemoveCLI: + """CLI tests for `specify extension remove` confirmation prompt wording.""" + + def _install_ext(self, project_dir, ext_dir): + """Install extension and return the manager.""" + manager = ExtensionManager(project_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + return manager + + def test_remove_confirmation_singular_command(self, tmp_path, extension_dir): + """Confirmation prompt should say '1 command' (singular) when one command registered.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + manager = self._install_ext(project_dir, extension_dir) + # Inject registered_commands with 1 entry so cmd_count == 1 + manager.registry.update("test-ext", {"registered_commands": {"claude": ["speckit.test-ext.hello"]}}) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, ["extension", "remove", "test-ext"], input="n\n", catch_exceptions=False + ) + + assert "1 command" in result.output + assert "1 commands" not in result.output + + def test_remove_confirmation_plural_commands(self, tmp_path, extension_dir): + """Confirmation prompt should say '2 commands' (plural) when two commands registered.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + manager = self._install_ext(project_dir, extension_dir) + # Inject registered_commands with 2 entries so cmd_count == 2 + manager.registry.update("test-ext", {"registered_commands": {"claude": ["speckit.test-ext.hello", "speckit.test-ext.run"]}}) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, ["extension", "remove", "test-ext"], input="n\n", catch_exceptions=False + ) + + assert "2 commands" in result.output From b82e39ea3d681709a822e5420b9c5295207dc377 Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Sat, 11 Apr 2026 08:54:33 +0200 Subject: [PATCH 14/14] fix(agents): remove orphaned SKILL.md parent dirs on unregister For SKILL.md-based agents (codex, kimi), each command lives in its own subdirectory (e.g. .agents/skills/speckit-ext-cmd/SKILL.md). The previous unregister_commands() only unlinked the file, leaving an empty parent dir. Now attempts rmdir() on the parent when it differs from the agent commands dir. OSError is silenced so non-empty dirs (e.g. user files) are safely left. Adds test_unregister_skill_removes_parent_directory to cover this. Co-Authored-By: Claude Sonnet 4.6 --- src/specify_cli/agents.py | 9 ++++++++ tests/test_extensions.py | 48 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index ec7af88768..d9f3388a28 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -563,6 +563,15 @@ def unregister_commands( cmd_file = commands_dir / f"{output_name}{agent_config['extension']}" if cmd_file.exists(): cmd_file.unlink() + # For SKILL.md agents each command lives in its own subdirectory + # (e.g. .agents/skills/speckit-ext-cmd/SKILL.md). Remove the + # parent dir when it becomes empty to avoid orphaned directories. + parent = cmd_file.parent + if parent != commands_dir and parent.exists(): + try: + parent.rmdir() # no-op if dir still has other files + except OSError: + pass if agent_name == "copilot": prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" diff --git a/tests/test_extensions.py b/tests/test_extensions.py index dc274903ba..d9342887ef 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1715,6 +1715,54 @@ def test_non_copilot_agent_no_companion_file(self, extension_dir, project_dir): prompts_dir = project_dir / ".github" / "prompts" assert not prompts_dir.exists() + def test_unregister_skill_removes_parent_directory(self, project_dir, temp_dir): + """Unregistering a SKILL.md command should remove the empty parent subdirectory.""" + import yaml + + ext_dir = temp_dir / "cleanup-ext" + ext_dir.mkdir() + (ext_dir / "commands").mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "cleanup-ext", + "name": "Cleanup Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.cleanup-ext.run", + "file": "commands/run.md", + "description": "Run", + } + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + (ext_dir / "commands" / "run.md").write_text("---\ndescription: Run\n---\n\nBody") + + skills_dir = project_dir / ".agents" / "skills" + skills_dir.mkdir(parents=True) + + registrar = CommandRegistrar() + from specify_cli.extensions import ExtensionManifest + manifest = ExtensionManifest(ext_dir / "extension.yml") + registered = registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir) + + skill_subdir = skills_dir / "speckit-cleanup-ext-run" + assert skill_subdir.exists(), "Skill subdirectory should exist after registration" + assert (skill_subdir / "SKILL.md").exists() + + registrar.unregister_commands({"codex": ["speckit.cleanup-ext.run"]}, project_dir) + + assert not (skill_subdir / "SKILL.md").exists(), "SKILL.md should be removed" + assert not skill_subdir.exists(), "Empty parent subdirectory should be removed" + # ===== Utility Function Tests =====