diff --git a/src/specify_cli/extensions/command_add.py b/src/specify_cli/extensions/command_add.py index 1917663406..96b7125ea4 100644 --- a/src/specify_cli/extensions/command_add.py +++ b/src/specify_cli/extensions/command_add.py @@ -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"), @@ -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 ``), 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 ." + ) + console.print("\nUsage:") + console.print(" specify extension add ") + console.print(" specify extension add --from ") + console.print(" specify extension add --dev ") + raise typer.Exit(1) project_root = _commands._require_specify_project() # Validate priority @@ -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]"): diff --git a/tests/specify_cli/extensions/test_command_add.py b/tests/specify_cli/extensions/test_command_add.py index 4b67706704..fdf1a7fac5 100644 --- a/tests/specify_cli/extensions/test_command_add.py +++ b/tests/specify_cli/extensions/test_command_add.py @@ -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 `` 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 " 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