Skip to content

Commit 33a28ec

Browse files
mbachorikiamaeroplaneclaudeCopilot
authored
fix: unofficial PyPI warning (#1982) and legacy extension command name auto-correction (#2017) (#2027)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * Update docs/installation.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * fix(extensions): derive remove cmd_count from registry, fix wording 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 <noreply@anthropic.com> * fix(extensions): distinguish missing vs empty registered_commands in remove prompt 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * fix(extensions): clarify cmd_count comment — per-agent max, not total 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 <noreply@anthropic.com> * test(extensions): add CLI tests for remove confirmation pluralization 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * fix(extensions): drop alias pattern enforcement from _validate() Aliases are intentionally free-form to preserve community extension compatibility (e.g. 'speckit.verify' short aliases used by spec-kit-verify and other existing extensions). This aligns _validate() with the intent of upstream commit 4deb90f (fix: restore alias compatibility, #2110/#2125). Only type and None-normalization checks remain for aliases. Pattern enforcement continues for primary command names only. Updated tests to verify free-form aliases pass through unchanged with no warnings instead of being auto-corrected. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(extensions): guard against non-dict command entries in _validate() If provides.commands contains a non-mapping entry (e.g. an int or string), 'name' not in cmd raises TypeError instead of a user-facing ValidationError. Added isinstance(cmd, dict) check at the top of the loop. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: iamaeroplane <michal.bachorik@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent f0886bd commit 33a28ec

File tree

7 files changed

+334
-14
lines changed

7 files changed

+334
-14
lines changed

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ Spec-Driven Development **flips the script** on traditional software development
5050

5151
Choose your preferred installation method:
5252

53+
> **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.
54+
5355
#### Option 1: Persistent Installation (Recommended)
5456

5557
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
6264
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git
6365
```
6466

65-
Then use the tool directly:
67+
Then verify the correct version is installed:
68+
69+
```bash
70+
specify version
71+
```
72+
73+
And use the tool directly:
6674

6775
```bash
6876
# Create new project

docs/installation.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
## Installation
1212

13+
> **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.
14+
1315
### Initialize a New Project
1416

1517
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 <proje
6971

7072
## Verification
7173

74+
After installation, run the following command to confirm the correct version is installed:
75+
76+
```bash
77+
specify version
78+
```
79+
80+
This helps verify you are running the official Spec Kit build from GitHub, not an unrelated package with the same name.
81+
7282
After initialization, you should see the following commands available in your AI agent:
7383

7484
- `/speckit.specify` - Create specifications

src/specify_cli/__init__.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3318,6 +3318,10 @@ def extension_add(
33183318
console.print("\n[green]✓[/green] Extension installed successfully!")
33193319
console.print(f"\n[bold]{manifest.name}[/bold] (v{manifest.version})")
33203320
console.print(f" {manifest.description}")
3321+
3322+
for warning in manifest.warnings:
3323+
console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {warning}")
3324+
33213325
console.print("\n[bold cyan]Provided commands:[/bold cyan]")
33223326
for cmd in manifest.commands:
33233327
console.print(f" • {cmd['name']} - {cmd.get('description', '')}")
@@ -3371,15 +3375,28 @@ def extension_remove(
33713375

33723376
# Get extension info for command and skill counts
33733377
ext_manifest = manager.get_extension(extension_id)
3374-
cmd_count = len(ext_manifest.commands) if ext_manifest else 0
33753378
reg_meta = manager.registry.get(extension_id)
3379+
# Derive cmd_count from the registry's registered_commands (includes aliases)
3380+
# rather than from the manifest (primary commands only). Use max() across
3381+
# agents to get the per-agent count; sum() would double-count since users
3382+
# think in logical commands, not per-agent file counts.
3383+
# Use get() without a default so we can distinguish "key missing" (fall back
3384+
# to manifest) from "key present but empty dict" (zero commands registered).
3385+
registered_commands = reg_meta.get("registered_commands") if isinstance(reg_meta, dict) else None
3386+
if isinstance(registered_commands, dict):
3387+
cmd_count = max(
3388+
(len(v) for v in registered_commands.values() if isinstance(v, list)),
3389+
default=0,
3390+
)
3391+
else:
3392+
cmd_count = len(ext_manifest.commands) if ext_manifest else 0
33763393
raw_skills = reg_meta.get("registered_skills") if reg_meta else None
33773394
skill_count = len(raw_skills) if isinstance(raw_skills, list) else 0
33783395

33793396
# Confirm removal
33803397
if not force:
33813398
console.print("\n[yellow]⚠ This will remove:[/yellow]")
3382-
console.print(f" • {cmd_count} commands from AI agent")
3399+
console.print(f" • {cmd_count} command{'s' if cmd_count != 1 else ''} per agent")
33833400
if skill_count:
33843401
console.print(f" • {skill_count} agent skill(s)")
33853402
console.print(f" • Extension directory: .specify/extensions/{extension_id}/")

src/specify_cli/agents.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,15 @@ def unregister_commands(
660660
cmd_file = commands_dir / f"{output_name}{agent_config['extension']}"
661661
if cmd_file.exists():
662662
cmd_file.unlink()
663+
# For SKILL.md agents each command lives in its own subdirectory
664+
# (e.g. .agents/skills/speckit-ext-cmd/SKILL.md). Remove the
665+
# parent dir when it becomes empty to avoid orphaned directories.
666+
parent = cmd_file.parent
667+
if parent != commands_dir and parent.exists():
668+
try:
669+
parent.rmdir() # no-op if dir still has other files
670+
except OSError:
671+
pass
663672

664673
if agent_name == "copilot":
665674
prompt_file = (

src/specify_cli/extensions.py

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ def __init__(self, manifest_path: Path):
132132
ValidationError: If manifest is invalid
133133
"""
134134
self.path = manifest_path
135+
self.warnings: List[str] = []
135136
self.data = self._load_yaml(manifest_path)
136137
self._validate()
137138

@@ -217,17 +218,98 @@ def _validate(self):
217218
f"Hook '{hook_name}' missing required 'command' field"
218219
)
219220

220-
# Validate commands (if present)
221+
# Validate commands; track renames so hook references can be rewritten.
222+
rename_map: Dict[str, str] = {}
221223
for cmd in commands:
224+
if not isinstance(cmd, dict):
225+
raise ValidationError(
226+
"Each command entry in 'provides.commands' must be a mapping"
227+
)
222228
if "name" not in cmd or "file" not in cmd:
223229
raise ValidationError("Command missing 'name' or 'file'")
224230

225231
# Validate command name format
226-
if EXTENSION_COMMAND_NAME_PATTERN.match(cmd["name"]) is None:
232+
if not EXTENSION_COMMAND_NAME_PATTERN.match(cmd["name"]):
233+
corrected = self._try_correct_command_name(cmd["name"], ext["id"])
234+
if corrected:
235+
self.warnings.append(
236+
f"Command name '{cmd['name']}' does not follow the required pattern "
237+
f"'speckit.{{extension}}.{{command}}'. Registering as '{corrected}'. "
238+
f"The extension author should update the manifest to use this name."
239+
)
240+
rename_map[cmd["name"]] = corrected
241+
cmd["name"] = corrected
242+
else:
243+
raise ValidationError(
244+
f"Invalid command name '{cmd['name']}': "
245+
"must follow pattern 'speckit.{extension}.{command}'"
246+
)
247+
248+
# Validate alias types; no pattern enforcement on aliases — they are
249+
# intentionally free-form to preserve community extension compatibility
250+
# (e.g. 'speckit.verify' short aliases used by existing extensions).
251+
aliases = cmd.get("aliases")
252+
if aliases is None:
253+
cmd["aliases"] = []
254+
aliases = []
255+
if not isinstance(aliases, list):
227256
raise ValidationError(
228-
f"Invalid command name '{cmd['name']}': "
229-
"must follow pattern 'speckit.{extension}.{command}'"
257+
f"Aliases for command '{cmd['name']}' must be a list"
230258
)
259+
for alias in aliases:
260+
if not isinstance(alias, str):
261+
raise ValidationError(
262+
f"Aliases for command '{cmd['name']}' must be strings"
263+
)
264+
265+
# Rewrite any hook command references that pointed at a renamed command or
266+
# an alias-form ref (ext.cmd → speckit.ext.cmd). Always emit a warning when
267+
# the reference is changed so extension authors know to update the manifest.
268+
for hook_name, hook_data in self.data.get("hooks", {}).items():
269+
if not isinstance(hook_data, dict):
270+
raise ValidationError(
271+
f"Hook '{hook_name}' must be a mapping, got {type(hook_data).__name__}"
272+
)
273+
command_ref = hook_data.get("command")
274+
if not isinstance(command_ref, str):
275+
continue
276+
# Step 1: apply any rename from the auto-correction pass.
277+
after_rename = rename_map.get(command_ref, command_ref)
278+
# Step 2: lift alias-form '{ext_id}.cmd' to canonical 'speckit.{ext_id}.cmd'.
279+
parts = after_rename.split(".")
280+
if len(parts) == 2 and parts[0] == ext["id"]:
281+
final_ref = f"speckit.{ext['id']}.{parts[1]}"
282+
else:
283+
final_ref = after_rename
284+
if final_ref != command_ref:
285+
hook_data["command"] = final_ref
286+
self.warnings.append(
287+
f"Hook '{hook_name}' referenced command '{command_ref}'; "
288+
f"updated to canonical form '{final_ref}'. "
289+
f"The extension author should update the manifest."
290+
)
291+
292+
@staticmethod
293+
def _try_correct_command_name(name: str, ext_id: str) -> Optional[str]:
294+
"""Try to auto-correct a non-conforming command name to the required pattern.
295+
296+
Handles the two legacy formats used by community extensions:
297+
- 'speckit.command' → 'speckit.{ext_id}.command'
298+
- '{ext_id}.command' → 'speckit.{ext_id}.command'
299+
300+
The 'X.Y' form is only corrected when X matches ext_id to ensure the
301+
result passes the install-time namespace check. Any other prefix is
302+
uncorrectable and will produce a ValidationError at the call site.
303+
304+
Returns the corrected name, or None if no safe correction is possible.
305+
"""
306+
parts = name.split('.')
307+
if len(parts) == 2:
308+
if parts[0] == 'speckit' or parts[0] == ext_id:
309+
candidate = f"speckit.{ext_id}.{parts[1]}"
310+
if EXTENSION_COMMAND_NAME_PATTERN.match(candidate):
311+
return candidate
312+
return None
231313

232314
@property
233315
def id(self) -> str:

0 commit comments

Comments
 (0)