Skip to content

Commit 920b628

Browse files
iamaeroplaneclaude
andcommitted
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>
1 parent 72e2a53 commit 920b628

3 files changed

Lines changed: 86 additions & 6 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3067,6 +3067,10 @@ def extension_add(
30673067
console.print("\n[green]✓[/green] Extension installed successfully!")
30683068
console.print(f"\n[bold]{manifest.name}[/bold] (v{manifest.version})")
30693069
console.print(f" {manifest.description}")
3070+
3071+
for warning in manifest.warnings:
3072+
console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {warning}")
3073+
30703074
console.print("\n[bold cyan]Provided commands:[/bold cyan]")
30713075
for cmd in manifest.commands:
30723076
console.print(f" • {cmd['name']} - {cmd.get('description', '')}")

src/specify_cli/extensions.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ def __init__(self, manifest_path: Path):
130130
ValidationError: If manifest is invalid
131131
"""
132132
self.path = manifest_path
133+
self.warnings: List[str] = []
133134
self.data = self._load_yaml(manifest_path)
134135
self._validate()
135136

@@ -192,11 +193,40 @@ def _validate(self):
192193
raise ValidationError("Command missing 'name' or 'file'")
193194

194195
# Validate command name format
195-
if EXTENSION_COMMAND_NAME_PATTERN.match(cmd["name"]) is None:
196-
raise ValidationError(
197-
f"Invalid command name '{cmd['name']}': "
198-
"must follow pattern 'speckit.{extension}.{command}'"
199-
)
196+
if not EXTENSION_COMMAND_NAME_PATTERN.match(cmd["name"]):
197+
corrected = self._try_correct_command_name(cmd["name"], ext["id"])
198+
if corrected:
199+
self.warnings.append(
200+
f"Command name '{cmd['name']}' does not follow the required pattern "
201+
f"'speckit.{{extension}}.{{command}}'. Registering as '{corrected}'. "
202+
f"The extension author should update the manifest to use this name."
203+
)
204+
cmd["name"] = corrected
205+
else:
206+
raise ValidationError(
207+
f"Invalid command name '{cmd['name']}': "
208+
"must follow pattern 'speckit.{extension}.{command}'"
209+
)
210+
211+
@staticmethod
212+
def _try_correct_command_name(name: str, ext_id: str) -> Optional[str]:
213+
"""Try to auto-correct a non-conforming command name to the required pattern.
214+
215+
Handles the two most common legacy formats used by community extensions:
216+
- 'speckit.command' → 'speckit.{ext_id}.command'
217+
- 'extension.command' → 'speckit.extension.command'
218+
219+
Returns the corrected name, or None if no safe correction is possible.
220+
"""
221+
parts = name.split('.')
222+
if len(parts) == 2:
223+
if parts[0] == 'speckit':
224+
candidate = f"speckit.{ext_id}.{parts[1]}"
225+
else:
226+
candidate = f"speckit.{parts[0]}.{parts[1]}"
227+
if EXTENSION_COMMAND_NAME_PATTERN.match(candidate):
228+
return candidate
229+
return None
200230

201231
@property
202232
def id(self) -> str:

tests/test_extensions.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ def test_invalid_version(self, temp_dir, valid_manifest_data):
242242
ExtensionManifest(manifest_path)
243243

244244
def test_invalid_command_name(self, temp_dir, valid_manifest_data):
245-
"""Test manifest with invalid command name format."""
245+
"""Test manifest with command name that cannot be auto-corrected raises ValidationError."""
246246
import yaml
247247

248248
valid_manifest_data["provides"]["commands"][0]["name"] = "invalid-name"
@@ -254,6 +254,52 @@ def test_invalid_command_name(self, temp_dir, valid_manifest_data):
254254
with pytest.raises(ValidationError, match="Invalid command name"):
255255
ExtensionManifest(manifest_path)
256256

257+
def test_command_name_autocorrect_speckit_prefix(self, temp_dir, valid_manifest_data):
258+
"""Test that 'speckit.command' is auto-corrected to 'speckit.{ext_id}.command'."""
259+
import yaml
260+
261+
valid_manifest_data["provides"]["commands"][0]["name"] = "speckit.hello"
262+
263+
manifest_path = temp_dir / "extension.yml"
264+
with open(manifest_path, 'w') as f:
265+
yaml.dump(valid_manifest_data, f)
266+
267+
manifest = ExtensionManifest(manifest_path)
268+
269+
assert manifest.commands[0]["name"] == "speckit.test-ext.hello"
270+
assert len(manifest.warnings) == 1
271+
assert "speckit.hello" in manifest.warnings[0]
272+
assert "speckit.test-ext.hello" in manifest.warnings[0]
273+
274+
def test_command_name_autocorrect_no_speckit_prefix(self, temp_dir, valid_manifest_data):
275+
"""Test that 'extension.command' is auto-corrected to 'speckit.extension.command'."""
276+
import yaml
277+
278+
valid_manifest_data["provides"]["commands"][0]["name"] = "docguard.guard"
279+
280+
manifest_path = temp_dir / "extension.yml"
281+
with open(manifest_path, 'w') as f:
282+
yaml.dump(valid_manifest_data, f)
283+
284+
manifest = ExtensionManifest(manifest_path)
285+
286+
assert manifest.commands[0]["name"] == "speckit.docguard.guard"
287+
assert len(manifest.warnings) == 1
288+
assert "docguard.guard" in manifest.warnings[0]
289+
assert "speckit.docguard.guard" in manifest.warnings[0]
290+
291+
def test_valid_command_name_has_no_warnings(self, temp_dir, valid_manifest_data):
292+
"""Test that a correctly-named command produces no warnings."""
293+
import yaml
294+
295+
manifest_path = temp_dir / "extension.yml"
296+
with open(manifest_path, 'w') as f:
297+
yaml.dump(valid_manifest_data, f)
298+
299+
manifest = ExtensionManifest(manifest_path)
300+
301+
assert manifest.warnings == []
302+
257303
def test_no_commands(self, temp_dir, valid_manifest_data):
258304
"""Test manifest with no commands provided."""
259305
import yaml

0 commit comments

Comments
 (0)