mirror of
https://github.com/github/spec-kit.git
synced 2026-07-06 14:01:01 +08:00
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>
This commit is contained in:
@@ -3318,6 +3318,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', '')}")
|
||||
@@ -3371,15 +3375,28 @@ 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)
|
||||
# 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
|
||||
if 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} commands from AI agent")
|
||||
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}/")
|
||||
|
||||
@@ -660,6 +660,15 @@ class CommandRegistrar:
|
||||
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 = (
|
||||
|
||||
@@ -132,6 +132,7 @@ class ExtensionManifest:
|
||||
ValidationError: If manifest is invalid
|
||||
"""
|
||||
self.path = manifest_path
|
||||
self.warnings: List[str] = []
|
||||
self.data = self._load_yaml(manifest_path)
|
||||
self._validate()
|
||||
|
||||
@@ -217,17 +218,98 @@ class ExtensionManifest:
|
||||
f"Hook '{hook_name}' missing required 'command' field"
|
||||
)
|
||||
|
||||
# Validate commands (if present)
|
||||
# Validate commands; track renames so hook references can be rewritten.
|
||||
rename_map: Dict[str, str] = {}
|
||||
for cmd in commands:
|
||||
if not isinstance(cmd, dict):
|
||||
raise ValidationError(
|
||||
"Each command entry in 'provides.commands' must be a mapping"
|
||||
)
|
||||
if "name" not in cmd or "file" not in cmd:
|
||||
raise ValidationError("Command missing 'name' or 'file'")
|
||||
|
||||
# Validate command name format
|
||||
if EXTENSION_COMMAND_NAME_PATTERN.match(cmd["name"]) is None:
|
||||
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."
|
||||
)
|
||||
rename_map[cmd["name"]] = corrected
|
||||
cmd["name"] = corrected
|
||||
else:
|
||||
raise ValidationError(
|
||||
f"Invalid command name '{cmd['name']}': "
|
||||
"must follow pattern 'speckit.{extension}.{command}'"
|
||||
)
|
||||
|
||||
# Validate alias types; no pattern enforcement on aliases — they are
|
||||
# intentionally free-form to preserve community extension compatibility
|
||||
# (e.g. 'speckit.verify' short aliases used by existing extensions).
|
||||
aliases = cmd.get("aliases")
|
||||
if aliases is None:
|
||||
cmd["aliases"] = []
|
||||
aliases = []
|
||||
if not isinstance(aliases, list):
|
||||
raise ValidationError(
|
||||
f"Invalid command name '{cmd['name']}': "
|
||||
"must follow pattern 'speckit.{extension}.{command}'"
|
||||
f"Aliases for command '{cmd['name']}' must be a list"
|
||||
)
|
||||
for alias in aliases:
|
||||
if not isinstance(alias, str):
|
||||
raise ValidationError(
|
||||
f"Aliases for command '{cmd['name']}' must be strings"
|
||||
)
|
||||
|
||||
# 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 not isinstance(hook_data, dict):
|
||||
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
|
||||
# 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 command '{command_ref}'; "
|
||||
f"updated to canonical form '{final_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 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' or parts[0] == ext_id:
|
||||
candidate = f"speckit.{ext_id}.{parts[1]}"
|
||||
if EXTENSION_COMMAND_NAME_PATTERN.match(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
|
||||
Reference in New Issue
Block a user