Repository: meltano/meltano

Issue:
feature: Install multiple plugins of any type
### Feature scope

CLI (options, error messages, logging, etc.)

### Description

Currently, you can only leverage `meltano install` in parallel for all plugin types or all plugins of a specific type:

```sh
# all plugins
meltano install

# all extractors
meltano install [extractor|extractors]

# all loaders
meltano install [loader|loaders]
```

It would be great if you could install multiple plugins of any type - something like:

```sh
meltano install <extractor> <loader> <transformer> <utility>
```

This change would remove the need to specify a plugin type at all, since a plugin name is already unique to a Meltano project. This is currently not possible without a breaking change however, since a plugin type is required as the first argument to `meltano install` when specifying plugin names. #8228 introduced the `--from-file` option for `meltano config <plugin> set`, which accepts a special character `-` to refer to stdin - `meltano install` could reuse this or a similar concept to skip the plugin type argument and leverage parallel install:

```sh
meltano install - <extractor> <loader> <transformer> <utility>
```

Once a convention is established, this feature could be extended to `meltano remove` as well.

I have a POC of this working for `meltano install` locally, so happy to open a PR.


Discussion:
Also loosely related to #7066

Resolving patch:
diff --git a/src/meltano/cli/install.py b/src/meltano/cli/install.py
--- a/src/meltano/cli/install.py
+++ b/src/meltano/cli/install.py
@@ -18,13 +18,15 @@
     from meltano.core.project import Project
     from meltano.core.tracking import Tracker
 
+ANY = "-"
+
 logger = structlog.getLogger(__name__)
 
 
 @click.command(cls=PartialInstrumentedCmd, short_help="Install project dependencies.")
 @click.argument(
     "plugin_type",
-    type=click.Choice(PluginType.cli_arguments()),
+    type=click.Choice((*PluginType.cli_arguments(), ANY)),
     required=False,
 )
 @click.argument("plugin_name", nargs=-1, required=False)
@@ -74,14 +76,15 @@ def install(  # noqa: C901
     """
     tracker: Tracker = ctx.obj["tracker"]
     try:
-        if plugin_type:
+        if plugin_type and plugin_type != ANY:
             plugin_type = PluginType.from_cli_argument(plugin_type)
             plugins = project.plugins.get_plugins_of_type(plugin_type)
-            if plugin_name:
-                plugins = [plugin for plugin in plugins if plugin.name in plugin_name]
         else:
             plugins = list(project.plugins.plugins())
 
+        if plugin_name:
+            plugins = [plugin for plugin in plugins if plugin.name in plugin_name]
+
         if schedule_name:
             schedule_plugins = _get_schedule_plugins(
                 ctx.obj["project"],


Test patch:
diff --git a/tests/meltano/cli/test_install.py b/tests/meltano/cli/test_install.py
--- a/tests/meltano/cli/test_install.py
+++ b/tests/meltano/cli/test_install.py
@@ -36,6 +36,20 @@ def test_install(self, project, tap, tap_gitlab, target, dbt, cli_runner):
                 force=False,
             )
 
+        with mock.patch("meltano.cli.install.install_plugins") as install_plugin_mock:
+            install_plugin_mock.return_value = True
+
+            result = cli_runner.invoke(cli, ["install", "-"])
+            assert_cli_runner(result)
+
+            install_plugin_mock.assert_called_once_with(
+                project,
+                [tap, tap_gitlab, target, dbt],
+                parallelism=None,
+                clean=False,
+                force=False,
+            )
+
     @pytest.mark.usefixtures("dbt")
     def test_install_type(
         self,
@@ -164,6 +178,25 @@ def test_install_multiple(self, project, tap, tap_gitlab, cli_runner):
                 force=False,
             )
 
+    @pytest.mark.usefixtures("dbt")
+    def test_install_multiple_any_type(self, project, tap, target, dbt, cli_runner):
+        with mock.patch("meltano.cli.install.install_plugins") as install_plugin_mock:
+            install_plugin_mock.return_value = True
+
+            result = cli_runner.invoke(
+                cli,
+                ["install", "-", tap.name, target.name, dbt.name],
+            )
+            assert_cli_runner(result)
+
+            install_plugin_mock.assert_called_once_with(
+                project,
+                [tap, target, dbt],
+                parallelism=None,
+                clean=False,
+                force=False,
+            )
+
     def test_install_parallel(
         self,
         project,