Skip to content

Commit bcebcb2

Browse files
feat: Add read-only plugin management commands
Introduce `cforge plugins` command group (list, get, stats) backed by the gateway's /admin/plugins endpoints. Includes full test coverage and README documentation. Signed-off-by: Matthew Grigsby <38010437+MatthewGrigsby@users.noreply.github.com>
1 parent 4889daf commit bcebcb2

4 files changed

Lines changed: 272 additions & 0 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,17 @@ cforge prompts execute <prompt-id>
9696
# MCP Servers
9797
cforge mcp-servers list
9898
cforge mcp-servers update <mcp-server-id> [file.json]
99+
100+
# Plugins (read-only admin API)
101+
cforge plugins list [--search text] [--mode MODE] [--hook HOOK] [--tag TAG] [--json]
102+
cforge plugins get <plugin-name>
103+
cforge plugins stats
99104
```
100105

106+
Plugin commands call `/admin/plugins` endpoints and require:
107+
- `MCPGATEWAY_ADMIN_API_ENABLED=true` on the gateway
108+
- A token with `admin.plugins` permission
109+
101110
### Server Operations
102111

103112
```bash
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# -*- coding: utf-8 -*-
2+
"""Location: ./cforge/commands/resources/plugins.py
3+
Copyright 2025
4+
SPDX-License-Identifier: Apache-2.0
5+
Authors: Matthew Grigsby
6+
7+
CLI command group: plugins
8+
"""
9+
10+
# Standard
11+
from typing import Any, Dict, Optional
12+
13+
# Third-Party
14+
import typer
15+
16+
# First-Party
17+
from cforge.common import (
18+
AuthenticationError,
19+
CLIError,
20+
get_console,
21+
handle_exception,
22+
make_authenticated_request,
23+
print_json,
24+
print_table,
25+
)
26+
27+
28+
def _handle_plugins_exception(exception: Exception) -> None:
29+
"""Provide plugin-specific hints and raise a CLI error."""
30+
console = get_console()
31+
32+
if isinstance(exception, AuthenticationError):
33+
console.print("[yellow]Access denied. Requires admin.plugins permission.[/yellow]")
34+
elif isinstance(exception, CLIError) and "(404)" in str(exception):
35+
console.print("[yellow]Admin plugin API unavailable. Ensure MCPGATEWAY_ADMIN_API_ENABLED=true and gateway version supports /admin/plugins.[/yellow]")
36+
37+
handle_exception(exception)
38+
39+
40+
def plugins_list(
41+
search: Optional[str] = typer.Option(None, "--search", help="Search by plugin name, description, or author"),
42+
mode: Optional[str] = typer.Option(None, "--mode", help="Filter by mode (enforce, permissive, disabled)"),
43+
hook: Optional[str] = typer.Option(None, "--hook", help="Filter by hook type"),
44+
tag: Optional[str] = typer.Option(None, "--tag", help="Filter by plugin tag"),
45+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
46+
) -> None:
47+
"""List all plugins with optional filtering."""
48+
console = get_console()
49+
50+
try:
51+
params: Dict[str, Any] = {}
52+
if search:
53+
params["search"] = search
54+
if mode:
55+
params["mode"] = mode
56+
if hook:
57+
params["hook"] = hook
58+
if tag:
59+
params["tag"] = tag
60+
61+
result = make_authenticated_request("GET", "/admin/plugins", params=params if params else None)
62+
63+
if json_output:
64+
print_json(result, "Plugins")
65+
else:
66+
plugins: list[dict[str, Any]] = []
67+
if isinstance(result, dict):
68+
if "plugins" in result:
69+
raw_plugins = result.get("plugins", [])
70+
if isinstance(raw_plugins, list):
71+
plugins = raw_plugins
72+
elif isinstance(raw_plugins, dict):
73+
plugins = [raw_plugins]
74+
else:
75+
plugins = [result]
76+
elif isinstance(result, list):
77+
plugins = result
78+
79+
if plugins:
80+
print_table(plugins, "Plugins", ["name", "version", "author", "mode", "status", "priority", "hooks", "tags"])
81+
else:
82+
console.print("[yellow]No plugins found[/yellow]")
83+
84+
except Exception as e:
85+
_handle_plugins_exception(e)
86+
87+
88+
def plugins_get(
89+
name: str = typer.Argument(..., help="Plugin name"),
90+
) -> None:
91+
"""Get details for a specific plugin."""
92+
try:
93+
result = make_authenticated_request("GET", f"/admin/plugins/{name}")
94+
print_json(result, f"Plugin {name}")
95+
96+
except Exception as e:
97+
_handle_plugins_exception(e)
98+
99+
100+
def plugins_stats() -> None:
101+
"""Get plugin statistics."""
102+
try:
103+
result = make_authenticated_request("GET", "/admin/plugins/stats")
104+
print_json(result, "Plugin Statistics")
105+
106+
except Exception as e:
107+
_handle_plugins_exception(e)

cforge/main.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@
9797
a2a_toggle,
9898
a2a_invoke,
9999
)
100+
from cforge.commands.resources.plugins import (
101+
plugins_get,
102+
plugins_list,
103+
plugins_stats,
104+
)
100105

101106
# Get the main app singleton
102107
app = get_app()
@@ -232,6 +237,17 @@
232237
a2a_app.command("toggle")(a2a_toggle)
233238
a2a_app.command("invoke")(a2a_invoke)
234239

240+
# ---------------------------------------------------------------------------
241+
# Plugins command group
242+
# ---------------------------------------------------------------------------
243+
244+
plugins_app = typer.Typer(help="Manage gateway plugins (read-only)")
245+
app.add_typer(plugins_app, name="plugins", rich_help_panel="Resources")
246+
247+
plugins_app.command("list")(plugins_list)
248+
plugins_app.command("get")(plugins_get)
249+
plugins_app.command("stats")(plugins_stats)
250+
235251
# ---------------------------------------------------------------------------
236252
# Metrics command group
237253
# ---------------------------------------------------------------------------
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# -*- coding: utf-8 -*-
2+
"""Location: ./tests/commands/resources/test_plugins.py
3+
Copyright 2025
4+
SPDX-License-Identifier: Apache-2.0
5+
Authors: Matthew Grigsby
6+
7+
Tests for the plugins commands.
8+
"""
9+
10+
# Third-Party
11+
import pytest
12+
import typer
13+
14+
# First-Party
15+
from cforge.commands.resources.plugins import plugins_get, plugins_list, plugins_stats
16+
from cforge.common import AuthenticationError, CLIError
17+
from tests.conftest import patch_functions
18+
19+
20+
class TestPluginCommands:
21+
"""Tests for plugins commands."""
22+
23+
def test_plugins_list_success(self, mock_console) -> None:
24+
"""Test plugins list command with table output."""
25+
mock_response = {
26+
"plugins": [{"name": "pii_filter", "version": "1.0.0", "author": "ContextForge", "mode": "enforce", "status": "enabled", "priority": 10, "hooks": ["tool_pre_invoke"], "tags": ["security"]}],
27+
"total": 1,
28+
"enabled_count": 1,
29+
"disabled_count": 0,
30+
}
31+
32+
with patch_functions(
33+
"cforge.commands.resources.plugins",
34+
get_console=mock_console,
35+
make_authenticated_request={"return_value": mock_response},
36+
print_table=None,
37+
) as mocks:
38+
plugins_list(json_output=False)
39+
mocks.print_table.assert_called_once()
40+
41+
def test_plugins_list_json_output(self, mock_console) -> None:
42+
"""Test plugins list with JSON output."""
43+
mock_response = {"plugins": [], "total": 0, "enabled_count": 0, "disabled_count": 0}
44+
with patch_functions(
45+
"cforge.commands.resources.plugins",
46+
get_console=mock_console,
47+
make_authenticated_request={"return_value": mock_response},
48+
print_json=None,
49+
) as mocks:
50+
plugins_list(json_output=True)
51+
mocks.print_json.assert_called_once()
52+
53+
def test_plugins_list_no_results(self, mock_console) -> None:
54+
"""Test plugins list with no results."""
55+
mock_response = {"plugins": [], "total": 0, "enabled_count": 0, "disabled_count": 0}
56+
with patch_functions("cforge.commands.resources.plugins", get_console=mock_console, make_authenticated_request={"return_value": mock_response}):
57+
plugins_list(json_output=False)
58+
59+
assert any("No plugins found" in str(call) for call in mock_console.print.call_args_list)
60+
61+
def test_plugins_list_with_filters(self, mock_console) -> None:
62+
"""Test plugins list with all filters."""
63+
with patch_functions(
64+
"cforge.commands.resources.plugins",
65+
get_console=mock_console,
66+
make_authenticated_request={"return_value": {"plugins": [], "total": 0, "enabled_count": 0, "disabled_count": 0}},
67+
print_table=None,
68+
) as mocks:
69+
plugins_list(search="pii", mode="enforce", hook="tool_pre_invoke", tag="security", json_output=False)
70+
71+
call_args = mocks.make_authenticated_request.call_args
72+
assert call_args[0][0] == "GET"
73+
assert call_args[0][1] == "/admin/plugins"
74+
assert call_args[1]["params"] == {"search": "pii", "mode": "enforce", "hook": "tool_pre_invoke", "tag": "security"}
75+
76+
def test_plugins_list_error(self, mock_console) -> None:
77+
"""Test plugins list error handling."""
78+
with patch_functions("cforge.commands.resources.plugins", get_console=mock_console, make_authenticated_request={"side_effect": Exception("API error")}):
79+
with pytest.raises(typer.Exit):
80+
plugins_list(json_output=False)
81+
82+
def test_plugins_get_success(self, mock_console) -> None:
83+
"""Test plugins get command."""
84+
mock_plugin = {"name": "pii_filter", "version": "1.0.0"}
85+
with patch_functions(
86+
"cforge.commands.resources.plugins",
87+
get_console=mock_console,
88+
make_authenticated_request={"return_value": mock_plugin},
89+
print_json=None,
90+
) as mocks:
91+
plugins_get(name="pii_filter")
92+
mocks.print_json.assert_called_once()
93+
94+
def test_plugins_get_error(self, mock_console) -> None:
95+
"""Test plugins get error handling."""
96+
with patch_functions("cforge.commands.resources.plugins", get_console=mock_console, make_authenticated_request={"side_effect": Exception("API error")}):
97+
with pytest.raises(typer.Exit):
98+
plugins_get(name="pii_filter")
99+
100+
def test_plugins_stats_success(self, mock_console) -> None:
101+
"""Test plugins stats command."""
102+
mock_stats = {"total_plugins": 4, "enabled_plugins": 3, "disabled_plugins": 1, "plugins_by_hook": {"tool_pre_invoke": 3}, "plugins_by_mode": {"enforce": 3, "disabled": 1}}
103+
with patch_functions(
104+
"cforge.commands.resources.plugins",
105+
get_console=mock_console,
106+
make_authenticated_request={"return_value": mock_stats},
107+
print_json=None,
108+
) as mocks:
109+
plugins_stats()
110+
mocks.print_json.assert_called_once()
111+
112+
def test_plugins_stats_error(self, mock_console) -> None:
113+
"""Test plugins stats error handling."""
114+
with patch_functions("cforge.commands.resources.plugins", get_console=mock_console, make_authenticated_request={"side_effect": Exception("API error")}):
115+
with pytest.raises(typer.Exit):
116+
plugins_stats()
117+
118+
def test_plugins_list_forbidden_shows_permission_hint(self, mock_console) -> None:
119+
"""Test plugins list shows a targeted hint on forbidden/admin failures."""
120+
with patch_functions(
121+
"cforge.commands.resources.plugins",
122+
get_console=mock_console,
123+
make_authenticated_request={"side_effect": AuthenticationError("Authentication required but not configured")},
124+
):
125+
with pytest.raises(typer.Exit):
126+
plugins_list(json_output=False)
127+
128+
assert any("Requires admin.plugins permission" in str(call) for call in mock_console.print.call_args_list)
129+
130+
def test_plugins_list_not_found_shows_admin_api_hint(self, mock_console) -> None:
131+
"""Test plugins list shows an admin-api hint on 404."""
132+
with patch_functions(
133+
"cforge.commands.resources.plugins",
134+
get_console=mock_console,
135+
make_authenticated_request={"side_effect": CLIError("API request failed (404): Not Found")},
136+
):
137+
with pytest.raises(typer.Exit):
138+
plugins_list(json_output=False)
139+
140+
assert any("Admin plugin API unavailable" in str(call) for call in mock_console.print.call_args_list)

0 commit comments

Comments
 (0)