Skip to content

Commit ecb2478

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 d091265 commit ecb2478

3 files changed

Lines changed: 87 additions & 5 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3590,6 +3590,10 @@ def extension_add(
35903590
console.print("\n[green]✓[/green] Extension installed successfully!")
35913591
console.print(f"\n[bold]{manifest.name}[/bold] (v{manifest.version})")
35923592
console.print(f" {manifest.description}")
3593+
3594+
for warning in manifest.warnings:
3595+
console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {warning}")
3596+
35933597
console.print("\n[bold cyan]Provided commands:[/bold cyan]")
35943598
for cmd in manifest.commands:
35953599
console.print(f" • {cmd['name']} - {cmd.get('description', '')}")

src/specify_cli/extensions.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ def __init__(self, manifest_path: Path):
8787
ValidationError: If manifest is invalid
8888
"""
8989
self.path = manifest_path
90+
self.warnings: List[str] = []
9091
self.data = self._load_yaml(manifest_path)
9192
self._validate()
9293

@@ -150,10 +151,41 @@ def _validate(self):
150151

151152
# Validate command name format
152153
if not re.match(r'^speckit\.[a-z0-9-]+\.[a-z0-9-]+$', cmd["name"]):
153-
raise ValidationError(
154-
f"Invalid command name '{cmd['name']}': "
155-
"must follow pattern 'speckit.{extension}.{command}'"
156-
)
154+
corrected = self._try_correct_command_name(cmd["name"], ext["id"])
155+
if corrected:
156+
self.warnings.append(
157+
f"Command name '{cmd['name']}' does not follow the required pattern "
158+
f"'speckit.{{extension}}.{{command}}'. Registering as '{corrected}'. "
159+
f"The extension author should update the manifest to use this name."
160+
)
161+
cmd["name"] = corrected
162+
else:
163+
raise ValidationError(
164+
f"Invalid command name '{cmd['name']}': "
165+
"must follow pattern 'speckit.{extension}.{command}'"
166+
)
167+
168+
@staticmethod
169+
def _try_correct_command_name(name: str, ext_id: str) -> Optional[str]:
170+
"""Try to auto-correct a non-conforming command name to the required pattern.
171+
172+
Handles the two most common legacy formats used by community extensions:
173+
- 'speckit.command' → 'speckit.{ext_id}.command'
174+
- 'extension.command' → 'speckit.extension.command'
175+
176+
Returns the corrected name, or None if no safe correction is possible.
177+
"""
178+
parts = name.split('.')
179+
if len(parts) == 2:
180+
if parts[0] == 'speckit':
181+
# speckit.command → speckit.{ext_id}.command
182+
candidate = f"speckit.{ext_id}.{parts[1]}"
183+
else:
184+
# extension.command → speckit.extension.command
185+
candidate = f"speckit.{parts[0]}.{parts[1]}"
186+
if re.match(r'^speckit\.[a-z0-9-]+\.[a-z0-9-]+$', candidate):
187+
return candidate
188+
return None
157189

158190
@property
159191
def id(self) -> str:

tests/test_extensions.py

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

230230
def test_invalid_command_name(self, temp_dir, valid_manifest_data):
231-
"""Test manifest with invalid command name format."""
231+
"""Test manifest with command name that cannot be auto-corrected raises ValidationError."""
232232
import yaml
233233

234234
valid_manifest_data["provides"]["commands"][0]["name"] = "invalid-name"
@@ -240,6 +240,52 @@ def test_invalid_command_name(self, temp_dir, valid_manifest_data):
240240
with pytest.raises(ValidationError, match="Invalid command name"):
241241
ExtensionManifest(manifest_path)
242242

243+
def test_command_name_autocorrect_speckit_prefix(self, temp_dir, valid_manifest_data):
244+
"""Test that 'speckit.command' is auto-corrected to 'speckit.{ext_id}.command'."""
245+
import yaml
246+
247+
valid_manifest_data["provides"]["commands"][0]["name"] = "speckit.hello"
248+
249+
manifest_path = temp_dir / "extension.yml"
250+
with open(manifest_path, 'w') as f:
251+
yaml.dump(valid_manifest_data, f)
252+
253+
manifest = ExtensionManifest(manifest_path)
254+
255+
assert manifest.commands[0]["name"] == "speckit.test-ext.hello"
256+
assert len(manifest.warnings) == 1
257+
assert "speckit.hello" in manifest.warnings[0]
258+
assert "speckit.test-ext.hello" in manifest.warnings[0]
259+
260+
def test_command_name_autocorrect_no_speckit_prefix(self, temp_dir, valid_manifest_data):
261+
"""Test that 'extension.command' is auto-corrected to 'speckit.extension.command'."""
262+
import yaml
263+
264+
valid_manifest_data["provides"]["commands"][0]["name"] = "docguard.guard"
265+
266+
manifest_path = temp_dir / "extension.yml"
267+
with open(manifest_path, 'w') as f:
268+
yaml.dump(valid_manifest_data, f)
269+
270+
manifest = ExtensionManifest(manifest_path)
271+
272+
assert manifest.commands[0]["name"] == "speckit.docguard.guard"
273+
assert len(manifest.warnings) == 1
274+
assert "docguard.guard" in manifest.warnings[0]
275+
assert "speckit.docguard.guard" in manifest.warnings[0]
276+
277+
def test_valid_command_name_has_no_warnings(self, temp_dir, valid_manifest_data):
278+
"""Test that a correctly-named command produces no warnings."""
279+
import yaml
280+
281+
manifest_path = temp_dir / "extension.yml"
282+
with open(manifest_path, 'w') as f:
283+
yaml.dump(valid_manifest_data, f)
284+
285+
manifest = ExtensionManifest(manifest_path)
286+
287+
assert manifest.warnings == []
288+
243289
def test_no_commands(self, temp_dir, valid_manifest_data):
244290
"""Test manifest with no commands provided."""
245291
import yaml

0 commit comments

Comments
 (0)