argus-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- argus_cli/__init__.py +10 -0
- argus_cli/__main__.py +6 -0
- argus_cli/_console.py +47 -0
- argus_cli/client.py +446 -0
- argus_cli/commands/__init__.py +1 -0
- argus_cli/commands/audit.py +119 -0
- argus_cli/commands/auth.py +112 -0
- argus_cli/commands/backends.py +395 -0
- argus_cli/commands/batch.py +121 -0
- argus_cli/commands/config_cmd.py +171 -0
- argus_cli/commands/config_server.py +305 -0
- argus_cli/commands/containers.py +392 -0
- argus_cli/commands/events.py +93 -0
- argus_cli/commands/health.py +150 -0
- argus_cli/commands/operations.py +198 -0
- argus_cli/commands/pods.py +299 -0
- argus_cli/commands/prompts.py +128 -0
- argus_cli/commands/registry.py +255 -0
- argus_cli/commands/resources.py +78 -0
- argus_cli/commands/secrets.py +194 -0
- argus_cli/commands/server.py +269 -0
- argus_cli/commands/skills.py +204 -0
- argus_cli/commands/tools.py +208 -0
- argus_cli/commands/workflows.py +147 -0
- argus_cli/config.py +242 -0
- argus_cli/daemon_client.py +394 -0
- argus_cli/design.py +77 -0
- argus_cli/main.py +253 -0
- argus_cli/output.py +290 -0
- argus_cli/repl/__init__.py +29 -0
- argus_cli/repl/completions.py +166 -0
- argus_cli/repl/dispatch.py +105 -0
- argus_cli/repl/handlers.py +292 -0
- argus_cli/repl/loop.py +170 -0
- argus_cli/repl/state.py +109 -0
- argus_cli/repl/toolbar.py +79 -0
- argus_cli/theme.py +256 -0
- argus_cli/themes/catppuccin-frappe.yaml +18 -0
- argus_cli/themes/catppuccin-latte.yaml +18 -0
- argus_cli/themes/catppuccin-macchiato.yaml +18 -0
- argus_cli/themes/catppuccin-mocha.yaml +18 -0
- argus_cli/themes/dracula.yaml +18 -0
- argus_cli/themes/everforest.yaml +18 -0
- argus_cli/themes/gruvbox.yaml +18 -0
- argus_cli/themes/kanagawa.yaml +18 -0
- argus_cli/themes/monokai.yaml +18 -0
- argus_cli/themes/nord.yaml +18 -0
- argus_cli/themes/one-dark.yaml +18 -0
- argus_cli/themes/rose-pine-moon.yaml +18 -0
- argus_cli/themes/rose-pine.yaml +18 -0
- argus_cli/themes/solarized-dark.yaml +18 -0
- argus_cli/themes/solarized-light.yaml +18 -0
- argus_cli/themes/tokyo-night.yaml +18 -0
- argus_cli/tui/__init__.py +19 -0
- argus_cli/tui/_config_ops.py +119 -0
- argus_cli/tui/_constants.py +23 -0
- argus_cli/tui/_dev_launch.py +81 -0
- argus_cli/tui/_error_utils.py +81 -0
- argus_cli/tui/api_client.py +17 -0
- argus_cli/tui/app.py +1258 -0
- argus_cli/tui/argus.tcss +589 -0
- argus_cli/tui/commands.py +125 -0
- argus_cli/tui/events.py +74 -0
- argus_cli/tui/screens/__init__.py +25 -0
- argus_cli/tui/screens/_base_log.py +143 -0
- argus_cli/tui/screens/audit_log.py +226 -0
- argus_cli/tui/screens/backend_config.py +461 -0
- argus_cli/tui/screens/backend_detail.py +205 -0
- argus_cli/tui/screens/base.py +59 -0
- argus_cli/tui/screens/catalog_browser.py +227 -0
- argus_cli/tui/screens/client_config.py +244 -0
- argus_cli/tui/screens/containers.py +248 -0
- argus_cli/tui/screens/dashboard.py +67 -0
- argus_cli/tui/screens/elicitation.py +83 -0
- argus_cli/tui/screens/exit_modal.py +104 -0
- argus_cli/tui/screens/export_import.py +376 -0
- argus_cli/tui/screens/health.py +440 -0
- argus_cli/tui/screens/kubernetes.py +260 -0
- argus_cli/tui/screens/operations.py +144 -0
- argus_cli/tui/screens/registry.py +262 -0
- argus_cli/tui/screens/security.py +240 -0
- argus_cli/tui/screens/server_detail.py +170 -0
- argus_cli/tui/screens/server_logs.py +232 -0
- argus_cli/tui/screens/settings.py +494 -0
- argus_cli/tui/screens/setup_wizard.py +714 -0
- argus_cli/tui/screens/skills.py +472 -0
- argus_cli/tui/screens/theme_picker.py +116 -0
- argus_cli/tui/screens/tool_editor.py +327 -0
- argus_cli/tui/screens/tools.py +268 -0
- argus_cli/tui/server_manager.py +312 -0
- argus_cli/tui/settings.py +92 -0
- argus_cli/tui/widgets/__init__.py +15 -0
- argus_cli/tui/widgets/backend_status.py +223 -0
- argus_cli/tui/widgets/capability_tables.py +204 -0
- argus_cli/tui/widgets/container_logs.py +112 -0
- argus_cli/tui/widgets/container_stats.py +125 -0
- argus_cli/tui/widgets/container_table.py +104 -0
- argus_cli/tui/widgets/elicitation_form.py +133 -0
- argus_cli/tui/widgets/event_log.py +124 -0
- argus_cli/tui/widgets/filter_bar.py +176 -0
- argus_cli/tui/widgets/filter_toggle.py +64 -0
- argus_cli/tui/widgets/health_panel.py +263 -0
- argus_cli/tui/widgets/install_panel.py +125 -0
- argus_cli/tui/widgets/jump_overlay.py +180 -0
- argus_cli/tui/widgets/middleware_panel.py +124 -0
- argus_cli/tui/widgets/module_container.py +57 -0
- argus_cli/tui/widgets/network_panel.py +135 -0
- argus_cli/tui/widgets/optimizer_panel.py +213 -0
- argus_cli/tui/widgets/otel_panel.py +169 -0
- argus_cli/tui/widgets/param_editor.py +156 -0
- argus_cli/tui/widgets/percentage_bar.py +110 -0
- argus_cli/tui/widgets/pod_table.py +50 -0
- argus_cli/tui/widgets/quick_actions.py +94 -0
- argus_cli/tui/widgets/registry_browser.py +299 -0
- argus_cli/tui/widgets/registry_panel.py +137 -0
- argus_cli/tui/widgets/secrets_panel.py +276 -0
- argus_cli/tui/widgets/server_connections_panel.py +125 -0
- argus_cli/tui/widgets/server_groups.py +108 -0
- argus_cli/tui/widgets/server_info.py +127 -0
- argus_cli/tui/widgets/server_selector.py +98 -0
- argus_cli/tui/widgets/sessions_panel.py +201 -0
- argus_cli/tui/widgets/sync_status.py +164 -0
- argus_cli/tui/widgets/tool_ops_panel.py +167 -0
- argus_cli/tui/widgets/tool_preview.py +65 -0
- argus_cli/tui/widgets/toolbar.py +101 -0
- argus_cli/tui/widgets/tplot.py +288 -0
- argus_cli/tui/widgets/version_badge.py +47 -0
- argus_cli/tui/widgets/version_drift.py +216 -0
- argus_cli/tui/widgets/workflows_panel.py +590 -0
- argus_cli/widgets/__init__.py +23 -0
- argus_cli/widgets/banner.py +35 -0
- argus_cli/widgets/panels.py +80 -0
- argus_cli/widgets/spinners.py +83 -0
- argus_cli/widgets/tables.py +84 -0
- argus_cli-0.1.0.dist-info/METADATA +99 -0
- argus_cli-0.1.0.dist-info/RECORD +138 -0
- argus_cli-0.1.0.dist-info/WHEEL +4 -0
- argus_cli-0.1.0.dist-info/entry_points.txt +4 -0
argus_cli/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Argus CLI — Interactive command-line interface for Argus MCP."""
|
|
2
|
+
|
|
3
|
+
__all__ = ["__version__"]
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = version("argus-cli")
|
|
9
|
+
except PackageNotFoundError:
|
|
10
|
+
__version__ = "0.0.0-dev"
|
argus_cli/__main__.py
ADDED
argus_cli/_console.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Console singleton — shared by theme.py and output.py to avoid circular imports.
|
|
2
|
+
|
|
3
|
+
The module-global singleton pattern is intentional: Rich Console configuration
|
|
4
|
+
(theme, no_color) must be consistent across all output paths. ``reset_console()``
|
|
5
|
+
exists to support tests that need to reconfigure the console between runs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__all__ = ["get_console", "reset_console"]
|
|
11
|
+
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
_console: Console | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_console(no_color: bool | None = None) -> Console:
|
|
18
|
+
"""Get or create the Rich console singleton.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
no_color: Override color setting. When ``None``, reads from
|
|
22
|
+
the active CLI config.
|
|
23
|
+
"""
|
|
24
|
+
global _console
|
|
25
|
+
if _console is None:
|
|
26
|
+
from argus_cli.theme import ARGUS_THEME, _ensure_loaded
|
|
27
|
+
|
|
28
|
+
_ensure_loaded()
|
|
29
|
+
if no_color is None:
|
|
30
|
+
from argus_cli.config import get_config
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
no_color = get_config().no_color
|
|
34
|
+
except RuntimeError:
|
|
35
|
+
no_color = False
|
|
36
|
+
_console = Console(
|
|
37
|
+
theme=ARGUS_THEME,
|
|
38
|
+
no_color=no_color,
|
|
39
|
+
stderr=False,
|
|
40
|
+
)
|
|
41
|
+
return _console
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def reset_console() -> None:
|
|
45
|
+
"""Reset console singleton so the next call picks up a new theme."""
|
|
46
|
+
global _console
|
|
47
|
+
_console = None
|
argus_cli/client.py
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""HTTP API client for the Argus MCP Management API.
|
|
2
|
+
|
|
3
|
+
Thin adapter around :mod:`argus_mcp.api.client` that provides:
|
|
4
|
+
|
|
5
|
+
- **Sync** (``ArgusClient``) and **async** (``AsyncArgusClient``) wrappers
|
|
6
|
+
that return raw ``dict`` results for CLI output rendering.
|
|
7
|
+
- Retry with exponential back-off for transient transport errors.
|
|
8
|
+
- SSE streaming via ``httpx-sse``.
|
|
9
|
+
- ``CliConfig``-based initialisation so callers don't construct URLs manually.
|
|
10
|
+
|
|
11
|
+
The underlying HTTP contract (schemas, error model, endpoint paths) is shared
|
|
12
|
+
with the TUI client via ``argus_mcp.api``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
__all__ = ["ArgusClient", "ArgusClientError", "AsyncArgusClient"]
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from collections.abc import AsyncGenerator
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
if sys.version_info >= (3, 11):
|
|
26
|
+
from typing import Self
|
|
27
|
+
else:
|
|
28
|
+
from typing_extensions import Self
|
|
29
|
+
|
|
30
|
+
import httpx
|
|
31
|
+
from argus_mcp.api.client import ApiClientError
|
|
32
|
+
|
|
33
|
+
from argus_cli.config import CliConfig
|
|
34
|
+
|
|
35
|
+
# ── Timeout configuration ──────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
DEFAULT_TIMEOUT = httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=5.0)
|
|
38
|
+
SSE_TIMEOUT = httpx.Timeout(connect=5.0, read=None, write=10.0, pool=5.0)
|
|
39
|
+
|
|
40
|
+
# ── Retry configuration ────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
MAX_RETRIES = 3
|
|
43
|
+
RETRY_BACKOFF_BASE = 0.5 # seconds; doubles each attempt
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ArgusClientError(ApiClientError):
|
|
47
|
+
"""CLI-specific API error with structured status_code / error / message."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, status_code: int, error: str, message: str) -> None:
|
|
50
|
+
self.status_code = status_code
|
|
51
|
+
self.error = error
|
|
52
|
+
self.message = message
|
|
53
|
+
super().__init__(f"[{status_code}] {error}: {message}", status_code=status_code or None)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _build_headers(config: CliConfig) -> dict[str, str]:
|
|
57
|
+
"""Build request headers including optional auth token."""
|
|
58
|
+
headers: dict[str, str] = {"Accept": "application/json"}
|
|
59
|
+
if config.token:
|
|
60
|
+
headers["Authorization"] = f"Bearer {config.token}"
|
|
61
|
+
return headers
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _build_group_params(group: str | None) -> dict[str, str]:
|
|
65
|
+
"""Build query params for the /groups endpoint."""
|
|
66
|
+
return {"group": group} if group else {}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _build_capabilities_params(
|
|
70
|
+
*,
|
|
71
|
+
type_filter: str | None = None,
|
|
72
|
+
backend: str | None = None,
|
|
73
|
+
search: str | None = None,
|
|
74
|
+
) -> dict[str, str]:
|
|
75
|
+
"""Build query params for the /capabilities endpoint."""
|
|
76
|
+
params: dict[str, str] = {}
|
|
77
|
+
if type_filter:
|
|
78
|
+
params["type"] = type_filter
|
|
79
|
+
if backend:
|
|
80
|
+
params["backend"] = backend
|
|
81
|
+
if search:
|
|
82
|
+
params["search"] = search
|
|
83
|
+
return params
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _build_events_params(
|
|
87
|
+
*,
|
|
88
|
+
limit: int = 100,
|
|
89
|
+
since: str | None = None,
|
|
90
|
+
severity: str | None = None,
|
|
91
|
+
) -> dict[str, str]:
|
|
92
|
+
"""Build query params for the /events endpoint."""
|
|
93
|
+
params: dict[str, str] = {"limit": str(limit)}
|
|
94
|
+
if since:
|
|
95
|
+
params["since"] = since
|
|
96
|
+
if severity:
|
|
97
|
+
params["severity"] = severity
|
|
98
|
+
return params
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _handle_response(response: httpx.Response) -> dict[str, Any]:
|
|
102
|
+
"""Parse response JSON and raise on error status codes."""
|
|
103
|
+
if response.status_code >= 400:
|
|
104
|
+
try:
|
|
105
|
+
body = response.json()
|
|
106
|
+
raise ArgusClientError(
|
|
107
|
+
status_code=response.status_code,
|
|
108
|
+
error=body.get("error", "unknown_error"),
|
|
109
|
+
message=body.get("message", response.text),
|
|
110
|
+
)
|
|
111
|
+
except (json.JSONDecodeError, KeyError) as exc:
|
|
112
|
+
raise ArgusClientError(
|
|
113
|
+
status_code=response.status_code,
|
|
114
|
+
error="http_error",
|
|
115
|
+
message=response.text,
|
|
116
|
+
) from exc
|
|
117
|
+
try:
|
|
118
|
+
result: dict[str, Any] = response.json()
|
|
119
|
+
return result
|
|
120
|
+
except json.JSONDecodeError as exc:
|
|
121
|
+
raise ArgusClientError(
|
|
122
|
+
status_code=response.status_code,
|
|
123
|
+
error="parse_error",
|
|
124
|
+
message="Response is not valid JSON",
|
|
125
|
+
) from exc
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ── Sync client (one-shot commands) ────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ArgusClient:
|
|
132
|
+
"""Synchronous httpx client for one-shot CLI commands."""
|
|
133
|
+
|
|
134
|
+
def __init__(self, config: CliConfig) -> None:
|
|
135
|
+
"""Initialise the client with resolved CLI configuration.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
config: Resolved CLI configuration containing the server URL,
|
|
139
|
+
auth token, and timeout settings.
|
|
140
|
+
"""
|
|
141
|
+
self._config = config
|
|
142
|
+
self._client = httpx.Client(
|
|
143
|
+
base_url=config.base_url,
|
|
144
|
+
headers=_build_headers(config),
|
|
145
|
+
timeout=DEFAULT_TIMEOUT,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def close(self) -> None:
|
|
149
|
+
self._client.close()
|
|
150
|
+
|
|
151
|
+
def __enter__(self) -> Self:
|
|
152
|
+
return self
|
|
153
|
+
|
|
154
|
+
def __exit__(self, *args: object) -> None:
|
|
155
|
+
self.close()
|
|
156
|
+
|
|
157
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
158
|
+
"""Send an HTTP request with transport error handling and retry."""
|
|
159
|
+
last_exc: ArgusClientError | None = None
|
|
160
|
+
for attempt in range(MAX_RETRIES):
|
|
161
|
+
try:
|
|
162
|
+
response = getattr(self._client, method)(path, **kwargs)
|
|
163
|
+
return _handle_response(response)
|
|
164
|
+
except httpx.ConnectError as exc:
|
|
165
|
+
last_exc = ArgusClientError(
|
|
166
|
+
0,
|
|
167
|
+
"connection_error",
|
|
168
|
+
f"Cannot connect to {self._config.server_url}",
|
|
169
|
+
)
|
|
170
|
+
last_exc.__cause__ = exc
|
|
171
|
+
except httpx.TimeoutException as exc:
|
|
172
|
+
last_exc = ArgusClientError(
|
|
173
|
+
0,
|
|
174
|
+
"timeout_error",
|
|
175
|
+
"Request timed out",
|
|
176
|
+
)
|
|
177
|
+
last_exc.__cause__ = exc
|
|
178
|
+
except httpx.TransportError as exc:
|
|
179
|
+
last_exc = ArgusClientError(
|
|
180
|
+
0,
|
|
181
|
+
"transport_error",
|
|
182
|
+
f"Network error: {exc}",
|
|
183
|
+
)
|
|
184
|
+
last_exc.__cause__ = exc
|
|
185
|
+
if attempt < MAX_RETRIES - 1:
|
|
186
|
+
time.sleep(RETRY_BACKOFF_BASE * (2**attempt))
|
|
187
|
+
raise last_exc # type: ignore[misc]
|
|
188
|
+
|
|
189
|
+
# ── GET endpoints ──────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
def health(self) -> dict[str, Any]:
|
|
192
|
+
return self._request("get", "/health")
|
|
193
|
+
|
|
194
|
+
def status(self) -> dict[str, Any]:
|
|
195
|
+
return self._request("get", "/status")
|
|
196
|
+
|
|
197
|
+
def backends(self) -> dict[str, Any]:
|
|
198
|
+
return self._request("get", "/backends")
|
|
199
|
+
|
|
200
|
+
def groups(self, group: str | None = None) -> dict[str, Any]:
|
|
201
|
+
return self._request("get", "/groups", params=_build_group_params(group))
|
|
202
|
+
|
|
203
|
+
def capabilities(
|
|
204
|
+
self,
|
|
205
|
+
*,
|
|
206
|
+
type_filter: str | None = None,
|
|
207
|
+
backend: str | None = None,
|
|
208
|
+
search: str | None = None,
|
|
209
|
+
) -> dict[str, Any]:
|
|
210
|
+
params = _build_capabilities_params(
|
|
211
|
+
type_filter=type_filter,
|
|
212
|
+
backend=backend,
|
|
213
|
+
search=search,
|
|
214
|
+
)
|
|
215
|
+
return self._request("get", "/capabilities", params=params)
|
|
216
|
+
|
|
217
|
+
def sessions(self) -> dict[str, Any]:
|
|
218
|
+
return self._request("get", "/sessions")
|
|
219
|
+
|
|
220
|
+
def events(
|
|
221
|
+
self,
|
|
222
|
+
*,
|
|
223
|
+
limit: int = 100,
|
|
224
|
+
since: str | None = None,
|
|
225
|
+
severity: str | None = None,
|
|
226
|
+
) -> dict[str, Any]:
|
|
227
|
+
params = _build_events_params(limit=limit, since=since, severity=severity)
|
|
228
|
+
return self._request("get", "/events", params=params)
|
|
229
|
+
|
|
230
|
+
# ── POST endpoints ─────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
def reload(self) -> dict[str, Any]:
|
|
233
|
+
return self._request("post", "/reload")
|
|
234
|
+
|
|
235
|
+
def reconnect(self, name: str) -> dict[str, Any]:
|
|
236
|
+
return self._request("post", f"/reconnect/{name}")
|
|
237
|
+
|
|
238
|
+
def shutdown(self, timeout_seconds: int = 30) -> dict[str, Any]:
|
|
239
|
+
return self._request("post", "/shutdown", json={"timeout_seconds": timeout_seconds})
|
|
240
|
+
|
|
241
|
+
# ── Registry endpoints ─────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
def registry_search(
|
|
244
|
+
self,
|
|
245
|
+
query: str,
|
|
246
|
+
*,
|
|
247
|
+
limit: int = 20,
|
|
248
|
+
registry: str | None = None,
|
|
249
|
+
) -> dict[str, Any]:
|
|
250
|
+
params: dict[str, Any] = {"q": query, "limit": limit}
|
|
251
|
+
if registry:
|
|
252
|
+
params["registry"] = registry
|
|
253
|
+
return self._request("get", "/registry/search", params=params)
|
|
254
|
+
|
|
255
|
+
# ── Skills endpoints ───────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
def skills_list(self) -> dict[str, Any]:
|
|
258
|
+
return self._request("get", "/skills")
|
|
259
|
+
|
|
260
|
+
def skills_enable(self, name: str) -> dict[str, Any]:
|
|
261
|
+
return self._request("post", f"/skills/{name}/enable")
|
|
262
|
+
|
|
263
|
+
def skills_disable(self, name: str) -> dict[str, Any]:
|
|
264
|
+
return self._request("post", f"/skills/{name}/disable")
|
|
265
|
+
|
|
266
|
+
def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
267
|
+
payload = {"tool": name, "arguments": arguments or {}}
|
|
268
|
+
return self._request("post", "/tools/call", json=payload)
|
|
269
|
+
|
|
270
|
+
def read_resource(self, uri: str) -> dict[str, Any]:
|
|
271
|
+
return self._request("post", "/resources/read", json={"uri": uri})
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ── Async client (REPL mode) ──────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class AsyncArgusClient:
|
|
278
|
+
"""Async httpx client for REPL mode."""
|
|
279
|
+
|
|
280
|
+
def __init__(self, config: CliConfig) -> None:
|
|
281
|
+
"""Initialise the async client with resolved CLI configuration.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
config: Resolved CLI configuration containing the server URL,
|
|
285
|
+
auth token, and timeout settings.
|
|
286
|
+
"""
|
|
287
|
+
self._config = config
|
|
288
|
+
self._client = httpx.AsyncClient(
|
|
289
|
+
base_url=config.base_url,
|
|
290
|
+
headers=_build_headers(config),
|
|
291
|
+
timeout=DEFAULT_TIMEOUT,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
async def close(self) -> None:
|
|
295
|
+
await self._client.aclose()
|
|
296
|
+
|
|
297
|
+
async def __aenter__(self) -> Self:
|
|
298
|
+
return self
|
|
299
|
+
|
|
300
|
+
async def __aexit__(self, *args: object) -> None:
|
|
301
|
+
await self.close()
|
|
302
|
+
|
|
303
|
+
async def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
304
|
+
"""Send an async HTTP request with transport error handling and retry."""
|
|
305
|
+
import asyncio
|
|
306
|
+
|
|
307
|
+
last_exc: ArgusClientError | None = None
|
|
308
|
+
for attempt in range(MAX_RETRIES):
|
|
309
|
+
try:
|
|
310
|
+
response = await getattr(self._client, method)(path, **kwargs)
|
|
311
|
+
return _handle_response(response)
|
|
312
|
+
except httpx.ConnectError as exc:
|
|
313
|
+
last_exc = ArgusClientError(
|
|
314
|
+
0,
|
|
315
|
+
"connection_error",
|
|
316
|
+
f"Cannot connect to {self._config.server_url}",
|
|
317
|
+
)
|
|
318
|
+
last_exc.__cause__ = exc
|
|
319
|
+
except httpx.TimeoutException as exc:
|
|
320
|
+
last_exc = ArgusClientError(
|
|
321
|
+
0,
|
|
322
|
+
"timeout_error",
|
|
323
|
+
"Request timed out",
|
|
324
|
+
)
|
|
325
|
+
last_exc.__cause__ = exc
|
|
326
|
+
except httpx.TransportError as exc:
|
|
327
|
+
last_exc = ArgusClientError(
|
|
328
|
+
0,
|
|
329
|
+
"transport_error",
|
|
330
|
+
f"Network error: {exc}",
|
|
331
|
+
)
|
|
332
|
+
last_exc.__cause__ = exc
|
|
333
|
+
if attempt < MAX_RETRIES - 1:
|
|
334
|
+
await asyncio.sleep(RETRY_BACKOFF_BASE * (2**attempt))
|
|
335
|
+
raise last_exc # type: ignore[misc]
|
|
336
|
+
|
|
337
|
+
# ── GET endpoints ──────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
async def health(self) -> dict[str, Any]:
|
|
340
|
+
return await self._request("get", "/health")
|
|
341
|
+
|
|
342
|
+
async def status(self) -> dict[str, Any]:
|
|
343
|
+
return await self._request("get", "/status")
|
|
344
|
+
|
|
345
|
+
async def backends(self) -> dict[str, Any]:
|
|
346
|
+
return await self._request("get", "/backends")
|
|
347
|
+
|
|
348
|
+
async def groups(self, group: str | None = None) -> dict[str, Any]:
|
|
349
|
+
return await self._request("get", "/groups", params=_build_group_params(group))
|
|
350
|
+
|
|
351
|
+
async def capabilities(
|
|
352
|
+
self,
|
|
353
|
+
*,
|
|
354
|
+
type_filter: str | None = None,
|
|
355
|
+
backend: str | None = None,
|
|
356
|
+
search: str | None = None,
|
|
357
|
+
) -> dict[str, Any]:
|
|
358
|
+
params = _build_capabilities_params(
|
|
359
|
+
type_filter=type_filter,
|
|
360
|
+
backend=backend,
|
|
361
|
+
search=search,
|
|
362
|
+
)
|
|
363
|
+
return await self._request("get", "/capabilities", params=params)
|
|
364
|
+
|
|
365
|
+
async def sessions(self) -> dict[str, Any]:
|
|
366
|
+
return await self._request("get", "/sessions")
|
|
367
|
+
|
|
368
|
+
async def events(
|
|
369
|
+
self,
|
|
370
|
+
*,
|
|
371
|
+
limit: int = 100,
|
|
372
|
+
since: str | None = None,
|
|
373
|
+
severity: str | None = None,
|
|
374
|
+
) -> dict[str, Any]:
|
|
375
|
+
params = _build_events_params(limit=limit, since=since, severity=severity)
|
|
376
|
+
return await self._request("get", "/events", params=params)
|
|
377
|
+
|
|
378
|
+
# ── POST endpoints ─────────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
async def reload(self) -> dict[str, Any]:
|
|
381
|
+
return await self._request("post", "/reload")
|
|
382
|
+
|
|
383
|
+
async def reconnect(self, name: str) -> dict[str, Any]:
|
|
384
|
+
return await self._request("post", f"/reconnect/{name}")
|
|
385
|
+
|
|
386
|
+
async def shutdown(self, timeout_seconds: int = 30) -> dict[str, Any]:
|
|
387
|
+
return await self._request("post", "/shutdown", json={"timeout_seconds": timeout_seconds})
|
|
388
|
+
|
|
389
|
+
# ── Registry endpoints ─────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
async def registry_search(
|
|
392
|
+
self,
|
|
393
|
+
query: str,
|
|
394
|
+
*,
|
|
395
|
+
limit: int = 20,
|
|
396
|
+
registry: str | None = None,
|
|
397
|
+
) -> dict[str, Any]:
|
|
398
|
+
params: dict[str, Any] = {"q": query, "limit": limit}
|
|
399
|
+
if registry:
|
|
400
|
+
params["registry"] = registry
|
|
401
|
+
return await self._request("get", "/registry/search", params=params)
|
|
402
|
+
|
|
403
|
+
# ── Skills endpoints ───────────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
async def skills_list(self) -> dict[str, Any]:
|
|
406
|
+
return await self._request("get", "/skills")
|
|
407
|
+
|
|
408
|
+
async def skills_enable(self, name: str) -> dict[str, Any]:
|
|
409
|
+
return await self._request("post", f"/skills/{name}/enable")
|
|
410
|
+
|
|
411
|
+
async def skills_disable(self, name: str) -> dict[str, Any]:
|
|
412
|
+
return await self._request("post", f"/skills/{name}/disable")
|
|
413
|
+
|
|
414
|
+
async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
415
|
+
payload = {"tool": name, "arguments": arguments or {}}
|
|
416
|
+
return await self._request("post", "/tools/call", json=payload)
|
|
417
|
+
|
|
418
|
+
async def read_resource(self, uri: str) -> dict[str, Any]:
|
|
419
|
+
return await self._request("post", "/resources/read", json={"uri": uri})
|
|
420
|
+
|
|
421
|
+
# ── SSE streaming ──────────────────────────────────────────────────
|
|
422
|
+
|
|
423
|
+
async def events_stream(self) -> AsyncGenerator[dict[str, Any], None]:
|
|
424
|
+
"""Yield SSE events from /events/stream as dicts.
|
|
425
|
+
|
|
426
|
+
Yields:
|
|
427
|
+
dict with keys: event, data, id (parsed from SSE format)
|
|
428
|
+
"""
|
|
429
|
+
from httpx_sse import aconnect_sse
|
|
430
|
+
|
|
431
|
+
async with aconnect_sse(
|
|
432
|
+
self._client,
|
|
433
|
+
"GET",
|
|
434
|
+
"/events/stream",
|
|
435
|
+
timeout=SSE_TIMEOUT,
|
|
436
|
+
) as event_source:
|
|
437
|
+
async for sse in event_source.aiter_sse():
|
|
438
|
+
try:
|
|
439
|
+
data = json.loads(sse.data)
|
|
440
|
+
except (json.JSONDecodeError, TypeError):
|
|
441
|
+
data = sse.data
|
|
442
|
+
yield {
|
|
443
|
+
"event": sse.event,
|
|
444
|
+
"data": data,
|
|
445
|
+
"id": sse.id,
|
|
446
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Commands package — Typer sub-apps for each command group."""
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Audit log commands — list, export."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["app"]
|
|
6
|
+
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from argus_cli.output import OutputOption
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(no_args_is_help=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.command("list")
|
|
17
|
+
def list_audit(
|
|
18
|
+
ctx: typer.Context,
|
|
19
|
+
limit: Annotated[int, typer.Option(help="Maximum entries to return.")] = 100,
|
|
20
|
+
type_filter: Annotated[str | None, typer.Option("--type", help="Filter by event type.")] = None,
|
|
21
|
+
search: Annotated[str | None, typer.Option(help="Search pattern.")] = None,
|
|
22
|
+
output_fmt: OutputOption = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
"""List audit log entries."""
|
|
25
|
+
from argus_cli.client import ArgusClient, ArgusClientError
|
|
26
|
+
from argus_cli.output import OutputSpec, apply_output_option, output, print_error
|
|
27
|
+
|
|
28
|
+
apply_output_option(output_fmt)
|
|
29
|
+
config = ctx.obj
|
|
30
|
+
try:
|
|
31
|
+
with ArgusClient(config) as client:
|
|
32
|
+
data = client.events(limit=limit)
|
|
33
|
+
events = data.get("events", data)
|
|
34
|
+
|
|
35
|
+
# Client-side type filter (--type)
|
|
36
|
+
if type_filter:
|
|
37
|
+
tf = type_filter.lower()
|
|
38
|
+
events = [e for e in events if tf in str(e.get("type", "")).lower()]
|
|
39
|
+
|
|
40
|
+
# Client-side search filter
|
|
41
|
+
if search:
|
|
42
|
+
q = search.lower()
|
|
43
|
+
events = [
|
|
44
|
+
e
|
|
45
|
+
for e in events
|
|
46
|
+
if q in str(e.get("message", "")).lower()
|
|
47
|
+
or q in str(e.get("stage", "")).lower()
|
|
48
|
+
or q in str(e.get("backend", "")).lower()
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
output(
|
|
52
|
+
events,
|
|
53
|
+
fmt=config.output_format,
|
|
54
|
+
spec=OutputSpec(
|
|
55
|
+
title="Audit Log",
|
|
56
|
+
columns=["timestamp", "stage", "severity", "message"],
|
|
57
|
+
key_field="severity",
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
except ArgusClientError as e:
|
|
61
|
+
print_error(f"Failed to get audit log: {e.message}")
|
|
62
|
+
raise typer.Exit(1) from None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.command()
|
|
66
|
+
def export(
|
|
67
|
+
ctx: typer.Context,
|
|
68
|
+
fmt: Annotated[
|
|
69
|
+
str, typer.Option("--format", "-f", help="Export format: json or csv.")
|
|
70
|
+
] = "json",
|
|
71
|
+
since: Annotated[str | None, typer.Option(help="Export since timestamp (ISO 8601).")] = None,
|
|
72
|
+
limit: Annotated[int, typer.Option(help="Maximum entries to export.")] = 1000,
|
|
73
|
+
output_file: Annotated[
|
|
74
|
+
str | None, typer.Option("--output", "-o", help="Output file (default: stdout).")
|
|
75
|
+
] = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Export audit log entries as JSON or CSV."""
|
|
78
|
+
import csv
|
|
79
|
+
import io
|
|
80
|
+
import json
|
|
81
|
+
import sys
|
|
82
|
+
|
|
83
|
+
from argus_cli.client import ArgusClient, ArgusClientError
|
|
84
|
+
from argus_cli.output import print_error, print_success
|
|
85
|
+
|
|
86
|
+
config = ctx.obj
|
|
87
|
+
try:
|
|
88
|
+
with ArgusClient(config) as client:
|
|
89
|
+
data = client.events(limit=limit, since=since)
|
|
90
|
+
events = data.get("events", [])
|
|
91
|
+
except ArgusClientError as e:
|
|
92
|
+
print_error(f"Failed to export audit log: {e.message}")
|
|
93
|
+
raise typer.Exit(1) from None
|
|
94
|
+
|
|
95
|
+
if fmt == "json":
|
|
96
|
+
content = json.dumps(events, indent=2, default=str)
|
|
97
|
+
elif fmt == "csv":
|
|
98
|
+
if not events:
|
|
99
|
+
content = ""
|
|
100
|
+
else:
|
|
101
|
+
buf = io.StringIO()
|
|
102
|
+
fieldnames = ["id", "timestamp", "stage", "severity", "message", "backend"]
|
|
103
|
+
writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction="ignore")
|
|
104
|
+
writer.writeheader()
|
|
105
|
+
for event in events:
|
|
106
|
+
writer.writerow(event)
|
|
107
|
+
content = buf.getvalue()
|
|
108
|
+
else:
|
|
109
|
+
print_error(f"Unsupported format: {fmt}. Use 'json' or 'csv'.")
|
|
110
|
+
raise typer.Exit(1) from None
|
|
111
|
+
|
|
112
|
+
if output_file:
|
|
113
|
+
with open(output_file, "w", encoding="utf-8") as f:
|
|
114
|
+
f.write(content)
|
|
115
|
+
print_success(f"Exported {len(events)} entries to {output_file}.")
|
|
116
|
+
else:
|
|
117
|
+
sys.stdout.write(content)
|
|
118
|
+
if not content.endswith("\n"):
|
|
119
|
+
sys.stdout.write("\n")
|