Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions src/specify_cli/extensions/command_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

@_commands.extension_app.command("add")
def extension_add(
extension: str = typer.Argument(help="Extension name or path"),
extension: Optional[str] = typer.Argument(None, help="Extension name or path (optional when --from is given)"),
dev: bool = typer.Option(False, "--dev", help="Install from local directory"),
from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"),
force: bool = typer.Option(False, "--force", help="Overwrite if already installed"),
Expand All @@ -33,6 +33,26 @@ def extension_add(
# Typer supplies its OptionInfo object instead of a parsed option value.
if not isinstance(version, str):
version = None
# Same for the optional positional name when a caller omits it.
if not isinstance(extension, str):
extension = None

# The name is optional only for URL installs (``--from <url>``), where the
# archive's manifest supplies the extension ID. ``--dev`` needs a local
# path, and a catalog install needs a name to look up.
if extension is None and (dev or not from_url):
if dev:
console.print("[red]Error:[/red] --dev requires the path to a local extension directory.")
else:
console.print(
"[red]Error:[/red] Missing extension name or source. "
"Provide an extension name, or install from a URL with --from <url>."
)
console.print("\nUsage:")
console.print(" specify extension add <name>")
console.print(" specify extension add --from <url>")
console.print(" specify extension add --dev <path>")
raise typer.Exit(1)

project_root = _commands._require_specify_project()
# Validate priority
Expand Down Expand Up @@ -99,7 +119,8 @@ def extension_add(
console.print("Cancelled")
raise typer.Exit(0)

safe_extension = _escape_markup(extension)
# With ``--from`` and no name, describe the install by its source URL.
safe_extension = _escape_markup(extension if extension is not None else str(from_url))

try:
with console.status(f"[cyan]Installing extension: {safe_extension}[/cyan]"):
Expand Down
63 changes: 63 additions & 0 deletions tests/specify_cli/extensions/test_command_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -875,6 +875,69 @@ def reject_oversized(*_args, **_kwargs):
assert "exceeds maximum size" in result.output
install.assert_not_called()

def test_add_from_url_without_extension_name(self, tmp_path):
"""``extension add --from <url>`` works with no positional name (#1)."""
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
url = "https://example.com/ext.zip"
seen = {}

def fake_install_from_url(manager, root, from_url, speckit_version, **kwargs):
seen["url"] = from_url
return SimpleNamespace(
id="x", name="X", version="1.0.0", description="",
warnings=[], commands=[], hooks=[],
)

with patch.object(Path, "cwd", return_value=project_dir), \
patch("typer.confirm", return_value=True), \
patch("specify_cli.extensions._commands.install_extension_from_url", side_effect=fake_install_from_url):
result = CliRunner().invoke(app, ["extension", "add", "--from", url], catch_exceptions=True)

output = strip_ansi(result.output)
assert result.exit_code == 0, output
assert "Missing argument" not in output
assert seen["url"] == url
assert "Extension installed successfully" in output

def test_add_without_name_or_source_errors_clearly(self, tmp_path):
"""No name and no --from prints a clear error and installs nothing."""
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()

with patch.object(Path, "cwd", return_value=project_dir), \
patch.object(ExtensionManager, "install_from_zip") as install_zip, \
patch.object(ExtensionManager, "install_from_directory") as install_dir:
result = CliRunner().invoke(app, ["extension", "add"], catch_exceptions=True)

output = strip_ansi(result.output)
assert result.exit_code == 1, output
assert result.exception is None or isinstance(result.exception, SystemExit)
assert "Missing extension name or source" in output
assert "--from <url>" in output
assert "Traceback" not in output
install_zip.assert_not_called()
install_dir.assert_not_called()

def test_add_dev_without_path_errors_clearly(self, tmp_path):
"""``--dev`` with no path is rejected instead of falling through."""
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()

with patch.object(Path, "cwd", return_value=project_dir):
result = CliRunner().invoke(
app,
["extension", "add", "--dev", "--from", "https://example.com/ext.zip"],
catch_exceptions=True,
)

output = strip_ansi(result.output)
assert result.exit_code == 1, output
assert "--dev requires the path" in output

def test_add_from_url_resolves_ghes_release_asset(self, tmp_path):
"""A GHES release-download URL resolves to /api/v3 with octet-stream Accept."""
from types import SimpleNamespace
Expand Down
Loading