From a7d3d10a259e2ec0a3ede67b674d69e8380caec0 Mon Sep 17 00:00:00 2001
From: Yuseok Jo <yuseok89@gmail.com>
Date: Sun, 14 Jun 2026 23:43:41 +0900
Subject: [PATCH] [AIP-94] Route airflow providers get through the API server

---
 .../airflow/cli/commands/provider_command.py  | 42 ++++++----
 .../cli/commands/test_command_deprecations.py |  7 +-
 .../cli/commands/test_provider_command.py     | 77 +++++++++++++++++++
 airflow-ctl/docs/images/command_hashes.txt    |  2 +-
 airflow-ctl/docs/images/output_providers.svg  | 66 ++++++++--------
 airflow-ctl/src/airflowctl/ctl/cli_config.py  | 24 ++++++
 .../ctl/commands/provider_command.py          | 38 +++++++++
 .../ctl/commands/test_provider_command.py     | 72 +++++++++++++++++
 8 files changed, 279 insertions(+), 49 deletions(-)
 create mode 100644 airflow-core/tests/unit/cli/commands/test_provider_command.py
 create mode 100644 airflow-ctl/src/airflowctl/ctl/commands/provider_command.py
 create mode 100644 airflow-ctl/tests/airflow_ctl/ctl/commands/test_provider_command.py

diff --git a/airflow-core/src/airflow/cli/commands/provider_command.py b/airflow-core/src/airflow/cli/commands/provider_command.py
index 645618fd852cc..9175922676d72 100644
--- a/airflow-core/src/airflow/cli/commands/provider_command.py
+++ b/airflow-core/src/airflow/cli/commands/provider_command.py
@@ -21,7 +21,9 @@
 import re
 import sys
 
+from airflow.cli.api_client import NEW_API_CLIENT, Client, provide_api_client
 from airflow.cli.simple_table import AirflowConsole
+from airflow.cli.utils import deprecated_for_airflowctl
 from airflow.providers_manager import ProvidersManager
 from airflow.utils.cli import suppress_logs_and_warning
 from airflow.utils.providers_configuration_loader import providers_configuration_loaded
@@ -33,27 +35,35 @@ def _remove_rst_syntax(value: str) -> str:
     return re.sub("[`_<>]", "", value.strip(" \n."))
 
 
+@deprecated_for_airflowctl("airflowctl providers get")
 @suppress_logs_and_warning
 @providers_configuration_loaded
-def provider_get(args):
+@provide_api_client
+def provider_get(args, api_client: Client = NEW_API_CLIENT):
     """Get a provider info."""
-    providers = ProvidersManager().providers
-    if args.provider_name in providers:
-        provider_version = providers[args.provider_name].version
-        provider_info = providers[args.provider_name].data
-        if args.full:
-            provider_info["description"] = _remove_rst_syntax(provider_info["description"])
-            AirflowConsole().print_as(
-                data=[provider_info],
-                output=args.output,
-            )
-        else:
-            AirflowConsole().print_as(
-                data=[{"Provider": args.provider_name, "Version": provider_version}], output=args.output
-            )
-    else:
+    # No single-provider API endpoint exists, so filter the providers collection by name.
+    providers = {provider.package_name: provider for provider in api_client.providers.list().providers}
+    provider = providers.get(args.provider_name)
+    if provider is None:
         raise SystemExit(f"No such provider installed: {args.provider_name}")
 
+    if args.full:
+        AirflowConsole().print_as(
+            data=[
+                {
+                    "package_name": provider.package_name,
+                    "version": provider.version,
+                    "description": _remove_rst_syntax(provider.description),
+                    "documentation_url": provider.documentation_url,
+                }
+            ],
+            output=args.output,
+        )
+    else:
+        AirflowConsole().print_as(
+            data=[{"Provider": provider.package_name, "Version": provider.version}], output=args.output
+        )
+
 
 @suppress_logs_and_warning
 @providers_configuration_loaded
diff --git a/airflow-core/tests/unit/cli/commands/test_command_deprecations.py b/airflow-core/tests/unit/cli/commands/test_command_deprecations.py
index b4eb6840c9069..92e13cee40361 100644
--- a/airflow-core/tests/unit/cli/commands/test_command_deprecations.py
+++ b/airflow-core/tests/unit/cli/commands/test_command_deprecations.py
@@ -30,7 +30,7 @@
 
 import pytest
 
-from airflow.cli.commands import asset_command, dag_command, pool_command
+from airflow.cli.commands import asset_command, dag_command, pool_command, provider_command
 from airflow.exceptions import RemovedInAirflow4Warning
 
 # (command callable, argv to parse, expected airflowctl replacement named in the warning)
@@ -52,6 +52,11 @@
         ["assets", "materialize", "--name=foo"],
         "airflowctl assets materialize",
     ),
+    (
+        provider_command.provider_get,
+        ["providers", "get", "apache-airflow-providers-amazon"],
+        "airflowctl providers get",
+    ),
 ]
 
 
diff --git a/airflow-core/tests/unit/cli/commands/test_provider_command.py b/airflow-core/tests/unit/cli/commands/test_provider_command.py
new file mode 100644
index 0000000000000..a1d4da5588596
--- /dev/null
+++ b/airflow-core/tests/unit/cli/commands/test_provider_command.py
@@ -0,0 +1,77 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import json
+
+import pytest
+from airflowctl.api.datamodels.generated import ProviderResponse
+
+from airflow.cli import cli_parser
+from airflow.cli.commands import provider_command
+
+
+class TestCliProviderGet:
+    @classmethod
+    def setup_class(cls):
+        cls.parser = cli_parser.get_parser()
+
+    @staticmethod
+    def _providers() -> list[ProviderResponse]:
+        return [
+            ProviderResponse(
+                package_name="apache-airflow-providers-amazon",
+                description="Amazon provider",
+                version="9.0.0",
+                documentation_url="https://example.com",
+            )
+        ]
+
+    def test_provider_get(self, mock_cli_api_client, stdout_capture):
+        mock_cli_api_client.providers.list.return_value.providers = self._providers()
+        with stdout_capture as stdout:
+            provider_command.provider_get(
+                self.parser.parse_args(
+                    ["providers", "get", "apache-airflow-providers-amazon", "--output", "json"]
+                )
+            )
+        assert json.loads(stdout.getvalue()) == [
+            {"Provider": "apache-airflow-providers-amazon", "Version": "9.0.0"}
+        ]
+
+    def test_provider_get_full(self, mock_cli_api_client, stdout_capture):
+        mock_cli_api_client.providers.list.return_value.providers = self._providers()
+        with stdout_capture as stdout:
+            provider_command.provider_get(
+                self.parser.parse_args(
+                    ["providers", "get", "apache-airflow-providers-amazon", "--full", "--output", "json"]
+                )
+            )
+        assert json.loads(stdout.getvalue()) == [
+            {
+                "package_name": "apache-airflow-providers-amazon",
+                "version": "9.0.0",
+                "description": "Amazon provider",
+                "documentation_url": "https://example.com",
+            }
+        ]
+
+    def test_provider_get_not_found(self, mock_cli_api_client):
+        mock_cli_api_client.providers.list.return_value.providers = self._providers()
+        with pytest.raises(SystemExit, match="No such provider installed: does-not-exist"):
+            provider_command.provider_get(self.parser.parse_args(["providers", "get", "does-not-exist"]))
diff --git a/airflow-ctl/docs/images/command_hashes.txt b/airflow-ctl/docs/images/command_hashes.txt
index 53c93e7546d1e..b8a4b78e31825 100644
--- a/airflow-ctl/docs/images/command_hashes.txt
+++ b/airflow-ctl/docs/images/command_hashes.txt
@@ -8,7 +8,7 @@ dags:6b38e6bcd491bc1941e7814b77e63bde
 dagrun:c32e0011aa9a845456c778786717208e
 jobs:a5b644c5da8889443bb40ee10b599270
 pools:19efe105b9515ab1926ebcaf0e028d71
-providers:34502fe09dc0b8b0a13e7e46efdffda6
+providers:532e07c3c11c6100a753e23a921efabd
 variables:f8fc76d3d398b2780f4e97f7cd816646
 version:31f4efdf8de0dbaaa4fac71ff7efecc3
 plugins:4864fd8f356704bd2b3cd1aec3567e35
diff --git a/airflow-ctl/docs/images/output_providers.svg b/airflow-ctl/docs/images/output_providers.svg
index f4ce50e83cd57..983a543f3dfd5 100644
--- a/airflow-ctl/docs/images/output_providers.svg
+++ b/airflow-ctl/docs/images/output_providers.svg
@@ -1,4 +1,4 @@
-<svg class="rich-terminal" viewBox="0 0 933 294.0" xmlns="http://www.w3.org/2000/svg">
+<svg class="rich-terminal" viewBox="0 0 933 318.4" xmlns="http://www.w3.org/2000/svg">
     <!-- Generated with Rich https://www.textualize.io -->
     <style>
 
@@ -19,78 +19,82 @@
         font-weight: 700;
     }
 
-    .terminal-2177033504-matrix {
+    .terminal-622931817-matrix {
         font-family: Fira Code, monospace;
         font-size: 20px;
         line-height: 24.4px;
         font-variant-east-asian: full-width;
     }
 
-    .terminal-2177033504-title {
+    .terminal-622931817-title {
         font-size: 18px;
         font-weight: bold;
         font-family: arial;
     }
 
-    .terminal-2177033504-r1 { fill: #ff8700 }
-.terminal-2177033504-r2 { fill: #c5c8c6 }
-.terminal-2177033504-r3 { fill: #808080 }
-.terminal-2177033504-r4 { fill: #68a0b3 }
+    .terminal-622931817-r1 { fill: #ff8700 }
+.terminal-622931817-r2 { fill: #c5c8c6 }
+.terminal-622931817-r3 { fill: #808080 }
+.terminal-622931817-r4 { fill: #68a0b3 }
     </style>
 
     <defs>
-    <clipPath id="terminal-2177033504-clip-terminal">
-      <rect x="0" y="0" width="914.0" height="243.0" />
+    <clipPath id="terminal-622931817-clip-terminal">
+      <rect x="0" y="0" width="914.0" height="267.4" />
     </clipPath>
-    <clipPath id="terminal-2177033504-line-0">
+    <clipPath id="terminal-622931817-line-0">
     <rect x="0" y="1.5" width="915" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-2177033504-line-1">
+<clipPath id="terminal-622931817-line-1">
     <rect x="0" y="25.9" width="915" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-2177033504-line-2">
+<clipPath id="terminal-622931817-line-2">
     <rect x="0" y="50.3" width="915" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-2177033504-line-3">
+<clipPath id="terminal-622931817-line-3">
     <rect x="0" y="74.7" width="915" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-2177033504-line-4">
+<clipPath id="terminal-622931817-line-4">
     <rect x="0" y="99.1" width="915" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-2177033504-line-5">
+<clipPath id="terminal-622931817-line-5">
     <rect x="0" y="123.5" width="915" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-2177033504-line-6">
+<clipPath id="terminal-622931817-line-6">
     <rect x="0" y="147.9" width="915" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-2177033504-line-7">
+<clipPath id="terminal-622931817-line-7">
     <rect x="0" y="172.3" width="915" height="24.65"/>
             </clipPath>
-<clipPath id="terminal-2177033504-line-8">
+<clipPath id="terminal-622931817-line-8">
     <rect x="0" y="196.7" width="915" height="24.65"/>
             </clipPath>
+<clipPath id="terminal-622931817-line-9">
+    <rect x="0" y="221.1" width="915" height="24.65"/>
+            </clipPath>
     </defs>
 
-    <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="931" height="292" rx="8"/>
+    <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="931" height="316.4" rx="8"/>
             <g transform="translate(26,22)">
             <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
             <circle cx="22" cy="0" r="7" fill="#febc2e"/>
             <circle cx="44" cy="0" r="7" fill="#28c840"/>
             </g>
         
-    <g transform="translate(9, 41)" clip-path="url(#terminal-2177033504-clip-terminal)">
+    <g transform="translate(9, 41)" clip-path="url(#terminal-622931817-clip-terminal)">
     
-    <g class="terminal-2177033504-matrix">
-    <text class="terminal-2177033504-r1" x="0" y="20" textLength="73.2" clip-path="url(#terminal-2177033504-line-0)">Usage:</text><text class="terminal-2177033504-r3" x="85.4" y="20" textLength="244" clip-path="url(#terminal-2177033504-line-0)">airflowctl&#160;providers</text><text class="terminal-2177033504-r2" x="329.4" y="20" textLength="24.4" clip-path="url(#terminal-2177033504-line-0)">&#160;[</text><text class="terminal-2177033504-r4" x="353.8" y="20" textLength="24.4" clip-path="url(#terminal-2177033504-line-0)">-h</text><text class="terminal-2177033504-r2" x="378.2" y="20" textLength="24.4" clip-path="url(#terminal-2177033504-line-0)">]&#160;</text><text class="terminal-2177033504-r4" x="402.6" y="20" textLength="85.4" clip-path="url(#terminal-2177033504-line-0)">COMMAND</text><text class="terminal-2177033504-r4" x="500.2" y="20" textLength="36.6" clip-path="url(#terminal-2177033504-line-0)">...</text><text class="terminal-2177033504-r2" x="915" y="20" textLength="12.2" clip-path="url(#terminal-2177033504-line-0)">
-</text><text class="terminal-2177033504-r2" x="915" y="44.4" textLength="12.2" clip-path="url(#terminal-2177033504-line-1)">
-</text><text class="terminal-2177033504-r2" x="0" y="68.8" textLength="341.6" clip-path="url(#terminal-2177033504-line-2)">Perform&#160;Providers&#160;operations</text><text class="terminal-2177033504-r2" x="915" y="68.8" textLength="12.2" clip-path="url(#terminal-2177033504-line-2)">
-</text><text class="terminal-2177033504-r2" x="915" y="93.2" textLength="12.2" clip-path="url(#terminal-2177033504-line-3)">
-</text><text class="terminal-2177033504-r1" x="0" y="117.6" textLength="256.2" clip-path="url(#terminal-2177033504-line-4)">Positional&#160;Arguments:</text><text class="terminal-2177033504-r2" x="915" y="117.6" textLength="12.2" clip-path="url(#terminal-2177033504-line-4)">
-</text><text class="terminal-2177033504-r4" x="24.4" y="142" textLength="85.4" clip-path="url(#terminal-2177033504-line-5)">COMMAND</text><text class="terminal-2177033504-r2" x="915" y="142" textLength="12.2" clip-path="url(#terminal-2177033504-line-5)">
-</text><text class="terminal-2177033504-r4" x="48.8" y="166.4" textLength="48.8" clip-path="url(#terminal-2177033504-line-6)">list</text><text class="terminal-2177033504-r2" x="170.8" y="166.4" textLength="439.2" clip-path="url(#terminal-2177033504-line-6)">List&#160;all&#160;installed&#160;Airflow&#160;providers</text><text class="terminal-2177033504-r2" x="915" y="166.4" textLength="12.2" clip-path="url(#terminal-2177033504-line-6)">
-</text><text class="terminal-2177033504-r2" x="915" y="190.8" textLength="12.2" clip-path="url(#terminal-2177033504-line-7)">
-</text><text class="terminal-2177033504-r1" x="0" y="215.2" textLength="97.6" clip-path="url(#terminal-2177033504-line-8)">Options:</text><text class="terminal-2177033504-r2" x="915" y="215.2" textLength="12.2" clip-path="url(#terminal-2177033504-line-8)">
-</text><text class="terminal-2177033504-r4" x="24.4" y="239.6" textLength="24.4" clip-path="url(#terminal-2177033504-line-9)">-h</text><text class="terminal-2177033504-r2" x="48.8" y="239.6" textLength="24.4" clip-path="url(#terminal-2177033504-line-9)">,&#160;</text><text class="terminal-2177033504-r4" x="73.2" y="239.6" textLength="73.2" clip-path="url(#terminal-2177033504-line-9)">--help</text><text class="terminal-2177033504-r2" x="170.8" y="239.6" textLength="378.2" clip-path="url(#terminal-2177033504-line-9)">show&#160;this&#160;help&#160;message&#160;and&#160;exit</text><text class="terminal-2177033504-r2" x="915" y="239.6" textLength="12.2" clip-path="url(#terminal-2177033504-line-9)">
+    <g class="terminal-622931817-matrix">
+    <text class="terminal-622931817-r1" x="0" y="20" textLength="73.2" clip-path="url(#terminal-622931817-line-0)">Usage:</text><text class="terminal-622931817-r3" x="85.4" y="20" textLength="244" clip-path="url(#terminal-622931817-line-0)">airflowctl&#160;providers</text><text class="terminal-622931817-r2" x="329.4" y="20" textLength="24.4" clip-path="url(#terminal-622931817-line-0)">&#160;[</text><text class="terminal-622931817-r4" x="353.8" y="20" textLength="24.4" clip-path="url(#terminal-622931817-line-0)">-h</text><text class="terminal-622931817-r2" x="378.2" y="20" textLength="24.4" clip-path="url(#terminal-622931817-line-0)">]&#160;</text><text class="terminal-622931817-r4" x="402.6" y="20" textLength="85.4" clip-path="url(#terminal-622931817-line-0)">COMMAND</text><text class="terminal-622931817-r4" x="500.2" y="20" textLength="36.6" clip-path="url(#terminal-622931817-line-0)">...</text><text class="terminal-622931817-r2" x="915" y="20" textLength="12.2" clip-path="url(#terminal-622931817-line-0)">
+</text><text class="terminal-622931817-r2" x="915" y="44.4" textLength="12.2" clip-path="url(#terminal-622931817-line-1)">
+</text><text class="terminal-622931817-r2" x="0" y="68.8" textLength="341.6" clip-path="url(#terminal-622931817-line-2)">Perform&#160;Providers&#160;operations</text><text class="terminal-622931817-r2" x="915" y="68.8" textLength="12.2" clip-path="url(#terminal-622931817-line-2)">
+</text><text class="terminal-622931817-r2" x="915" y="93.2" textLength="12.2" clip-path="url(#terminal-622931817-line-3)">
+</text><text class="terminal-622931817-r1" x="0" y="117.6" textLength="256.2" clip-path="url(#terminal-622931817-line-4)">Positional&#160;Arguments:</text><text class="terminal-622931817-r2" x="915" y="117.6" textLength="12.2" clip-path="url(#terminal-622931817-line-4)">
+</text><text class="terminal-622931817-r4" x="24.4" y="142" textLength="85.4" clip-path="url(#terminal-622931817-line-5)">COMMAND</text><text class="terminal-622931817-r2" x="915" y="142" textLength="12.2" clip-path="url(#terminal-622931817-line-5)">
+</text><text class="terminal-622931817-r4" x="48.8" y="166.4" textLength="36.6" clip-path="url(#terminal-622931817-line-6)">get</text><text class="terminal-622931817-r2" x="170.8" y="166.4" textLength="488" clip-path="url(#terminal-622931817-line-6)">Get&#160;information&#160;about&#160;a&#160;single&#160;provider.</text><text class="terminal-622931817-r2" x="915" y="166.4" textLength="12.2" clip-path="url(#terminal-622931817-line-6)">
+</text><text class="terminal-622931817-r4" x="48.8" y="190.8" textLength="48.8" clip-path="url(#terminal-622931817-line-7)">list</text><text class="terminal-622931817-r2" x="170.8" y="190.8" textLength="439.2" clip-path="url(#terminal-622931817-line-7)">List&#160;all&#160;installed&#160;Airflow&#160;providers</text><text class="terminal-622931817-r2" x="915" y="190.8" textLength="12.2" clip-path="url(#terminal-622931817-line-7)">
+</text><text class="terminal-622931817-r2" x="915" y="215.2" textLength="12.2" clip-path="url(#terminal-622931817-line-8)">
+</text><text class="terminal-622931817-r1" x="0" y="239.6" textLength="97.6" clip-path="url(#terminal-622931817-line-9)">Options:</text><text class="terminal-622931817-r2" x="915" y="239.6" textLength="12.2" clip-path="url(#terminal-622931817-line-9)">
+</text><text class="terminal-622931817-r4" x="24.4" y="264" textLength="24.4" clip-path="url(#terminal-622931817-line-10)">-h</text><text class="terminal-622931817-r2" x="48.8" y="264" textLength="24.4" clip-path="url(#terminal-622931817-line-10)">,&#160;</text><text class="terminal-622931817-r4" x="73.2" y="264" textLength="73.2" clip-path="url(#terminal-622931817-line-10)">--help</text><text class="terminal-622931817-r2" x="170.8" y="264" textLength="378.2" clip-path="url(#terminal-622931817-line-10)">show&#160;this&#160;help&#160;message&#160;and&#160;exit</text><text class="terminal-622931817-r2" x="915" y="264" textLength="12.2" clip-path="url(#terminal-622931817-line-10)">
 </text>
     </g>
     </g>
diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py b/airflow-ctl/src/airflowctl/ctl/cli_config.py
index 11ff4542e01ef..a04f0ec7199d5 100755
--- a/airflow-ctl/src/airflowctl/ctl/cli_config.py
+++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py
@@ -319,6 +319,16 @@ def _load_help_texts_yaml() -> dict[str, dict[str, str]]:
     action="store_true",
 )
 
+# Providers command args
+ARG_PROVIDER_NAME = Arg(
+    flags=("provider_name",), type=str, help="Provider package name, e.g. apache-airflow-providers-amazon"
+)
+ARG_PROVIDER_FULL = Arg(
+    flags=("-f", "--full"),
+    action="store_true",
+    help="Show full provider information instead of just the version.",
+)
+
 
 class ActionCommand(NamedTuple):
     """Single CLI command."""
@@ -1015,6 +1025,15 @@ def merge_commands(
     ),
 )
 
+PROVIDER_COMMANDS = (
+    ActionCommand(
+        name="get",
+        help="Get information about a single provider.",
+        func=lazy_load_command("airflowctl.ctl.commands.provider_command.get"),
+        args=(ARG_PROVIDER_NAME, ARG_PROVIDER_FULL, ARG_OUTPUT),
+    ),
+)
+
 core_commands: list[CLICommand] = [
     GroupCommand(
         name="auth",
@@ -1042,6 +1061,11 @@ def merge_commands(
         help="Manage Airflow pools",
         subcommands=POOL_COMMANDS,
     ),
+    GroupCommand(
+        name="providers",
+        help="Manage Airflow providers",
+        subcommands=PROVIDER_COMMANDS,
+    ),
     ActionCommand(
         name="version",
         help="Show version information",
diff --git a/airflow-ctl/src/airflowctl/ctl/commands/provider_command.py b/airflow-ctl/src/airflowctl/ctl/commands/provider_command.py
new file mode 100644
index 0000000000000..fdf5635f09928
--- /dev/null
+++ b/airflow-ctl/src/airflowctl/ctl/commands/provider_command.py
@@ -0,0 +1,38 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Providers commands."""
+
+from __future__ import annotations
+
+from airflowctl.api.client import NEW_API_CLIENT, ClientKind, provide_api_client
+from airflowctl.ctl.console_formatting import AirflowConsole
+
+
+@provide_api_client(kind=ClientKind.CLI)
+def get(args, api_client=NEW_API_CLIENT) -> None:
+    """Get information about a single installed provider."""
+    # No single-provider API endpoint exists, so filter the providers collection by name.
+    providers = {provider.package_name: provider for provider in api_client.providers.list().providers}
+    provider = providers.get(args.provider_name)
+    if provider is None:
+        raise SystemExit(f"No such provider installed: {args.provider_name}")
+
+    if args.full:
+        data = provider.model_dump(mode="json")
+    else:
+        data = {"package_name": provider.package_name, "version": provider.version}
+    AirflowConsole().print_as(data=[data], output=args.output)
diff --git a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_provider_command.py b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_provider_command.py
new file mode 100644
index 0000000000000..f22c7de92b15c
--- /dev/null
+++ b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_provider_command.py
@@ -0,0 +1,72 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from airflowctl.api.client import ClientKind
+from airflowctl.api.datamodels.generated import ProviderCollectionResponse, ProviderResponse
+from airflowctl.ctl import cli_parser
+from airflowctl.ctl.commands import provider_command
+
+
+class TestProviderCommands:
+    parser = cli_parser.get_parser()
+    provider = ProviderResponse(
+        package_name="apache-airflow-providers-amazon",
+        description="Amazon provider",
+        version="9.0.0",
+        documentation_url="https://example.com",
+    )
+    collection = ProviderCollectionResponse(providers=[provider], total_entries=1)
+
+    def _client(self, api_client_maker):
+        return api_client_maker(
+            path="/api/v2/providers",
+            response_json=self.collection.model_dump(mode="json"),
+            expected_http_status_code=200,
+            kind=ClientKind.CLI,
+        )
+
+    def test_get(self, api_client_maker, capsys):
+        provider_command.get(
+            self.parser.parse_args(
+                ["providers", "get", "apache-airflow-providers-amazon", "--output", "json"]
+            ),
+            api_client=self._client(api_client_maker),
+        )
+        assert json.loads(capsys.readouterr().out) == [
+            {"package_name": "apache-airflow-providers-amazon", "version": "9.0.0"}
+        ]
+
+    def test_get_full(self, api_client_maker, capsys):
+        provider_command.get(
+            self.parser.parse_args(
+                ["providers", "get", "apache-airflow-providers-amazon", "--full", "--output", "json"]
+            ),
+            api_client=self._client(api_client_maker),
+        )
+        assert json.loads(capsys.readouterr().out) == [self.provider.model_dump(mode="json")]
+
+    def test_get_not_found(self, api_client_maker):
+        with pytest.raises(SystemExit, match="No such provider installed: does-not-exist"):
+            provider_command.get(
+                self.parser.parse_args(["providers", "get", "does-not-exist"]),
+                api_client=self._client(api_client_maker),
+            )
