mxcpctl 0.0.1__tar.gz
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.
- mxcpctl-0.0.1/PKG-INFO +16 -0
- mxcpctl-0.0.1/README.md +188 -0
- mxcpctl-0.0.1/mxcpctl/__init__.py +3 -0
- mxcpctl-0.0.1/mxcpctl/cli.py +596 -0
- mxcpctl-0.0.1/mxcpctl.egg-info/PKG-INFO +16 -0
- mxcpctl-0.0.1/mxcpctl.egg-info/SOURCES.txt +11 -0
- mxcpctl-0.0.1/mxcpctl.egg-info/dependency_links.txt +1 -0
- mxcpctl-0.0.1/mxcpctl.egg-info/entry_points.txt +2 -0
- mxcpctl-0.0.1/mxcpctl.egg-info/requires.txt +12 -0
- mxcpctl-0.0.1/mxcpctl.egg-info/top_level.txt +1 -0
- mxcpctl-0.0.1/pyproject.toml +62 -0
- mxcpctl-0.0.1/setup.cfg +4 -0
- mxcpctl-0.0.1/tests/test_cli.py +9 -0
mxcpctl-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mxcpctl
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: CLI tool for managing MXCP instances via mxcpd
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: click>=8.1.0
|
|
7
|
+
Requires-Dist: httpx>=0.25.0
|
|
8
|
+
Requires-Dist: rich>=13.0.0
|
|
9
|
+
Requires-Dist: pyyaml>=6.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=7.4.0; extra == "dev"
|
|
12
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
13
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
14
|
+
Requires-Dist: mypy>=1.6.0; extra == "dev"
|
|
15
|
+
Requires-Dist: build>=1.0.0; extra == "dev"
|
|
16
|
+
Requires-Dist: twine>=4.0.0; extra == "dev"
|
mxcpctl-0.0.1/README.md
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# mxcpctl - MXCP Control CLI
|
|
2
|
+
|
|
3
|
+
Command-line interface for managing MXCP instances through mxcpd.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install mxcpctl
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### Basic Commands
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Check mxcpd health
|
|
17
|
+
mxcpctl health
|
|
18
|
+
|
|
19
|
+
# Instance status
|
|
20
|
+
mxcpctl status
|
|
21
|
+
mxcpctl status --instance prod-1
|
|
22
|
+
|
|
23
|
+
# Instance configuration
|
|
24
|
+
mxcpctl config
|
|
25
|
+
mxcpctl config --instance prod-1
|
|
26
|
+
|
|
27
|
+
# Trigger configuration reload
|
|
28
|
+
mxcpctl reload
|
|
29
|
+
mxcpctl reload --instance prod-1
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Endpoints
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# List all endpoints
|
|
36
|
+
mxcpctl endpoints list
|
|
37
|
+
|
|
38
|
+
# Filter by instance
|
|
39
|
+
mxcpctl endpoints list --instance prod-1
|
|
40
|
+
|
|
41
|
+
# Filter by type
|
|
42
|
+
mxcpctl endpoints list --type tool
|
|
43
|
+
mxcpctl endpoints list --type resource
|
|
44
|
+
mxcpctl endpoints list --type prompt
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Audit Logs
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# Query recent audit logs
|
|
51
|
+
mxcpctl audit query --limit 20
|
|
52
|
+
|
|
53
|
+
# Filter by various criteria
|
|
54
|
+
mxcpctl audit query \
|
|
55
|
+
--instance prod-1 \
|
|
56
|
+
--operation-type tool \
|
|
57
|
+
--operation-name hello_world \
|
|
58
|
+
--status success \
|
|
59
|
+
--user-id john@example.com \
|
|
60
|
+
--limit 50
|
|
61
|
+
|
|
62
|
+
# Get statistics
|
|
63
|
+
mxcpctl audit stats
|
|
64
|
+
mxcpctl audit stats --instance prod-1
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Telemetry
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# Telemetry receiver status
|
|
71
|
+
mxcpctl telemetry status
|
|
72
|
+
|
|
73
|
+
# List recent traces
|
|
74
|
+
mxcpctl telemetry traces
|
|
75
|
+
mxcpctl telemetry traces --limit 50
|
|
76
|
+
mxcpctl telemetry traces --endpoint hello_world
|
|
77
|
+
|
|
78
|
+
# View specific trace details
|
|
79
|
+
mxcpctl telemetry trace abc123def456
|
|
80
|
+
|
|
81
|
+
# Performance metrics
|
|
82
|
+
mxcpctl telemetry metrics
|
|
83
|
+
mxcpctl telemetry metrics --endpoint hello_world
|
|
84
|
+
mxcpctl telemetry metrics --window 1 # Last hour
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Configuration
|
|
88
|
+
|
|
89
|
+
Set connection parameters via environment variables or CLI flags:
|
|
90
|
+
|
|
91
|
+
### Environment Variables
|
|
92
|
+
```bash
|
|
93
|
+
export MXCPCTL_HOST=mxcpd.example.com
|
|
94
|
+
export MXCPCTL_PORT=8080
|
|
95
|
+
export MXCPCTL_TOKEN="your-api-token"
|
|
96
|
+
|
|
97
|
+
# Now run commands without flags
|
|
98
|
+
mxcpctl status
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### CLI Flags
|
|
102
|
+
```bash
|
|
103
|
+
mxcpctl --host mxcpd.example.com --port 8080 --token "your-token" status
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### HTTPS
|
|
107
|
+
```bash
|
|
108
|
+
mxcpctl --tls --host mxcpd.example.com status
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Output
|
|
112
|
+
|
|
113
|
+
mxcpctl uses [Rich](https://github.com/Textualize/rich) for beautiful terminal output:
|
|
114
|
+
- Color-coded status indicators
|
|
115
|
+
- Tables and formatted data
|
|
116
|
+
- Progress indicators
|
|
117
|
+
- Error highlighting
|
|
118
|
+
|
|
119
|
+
## Development
|
|
120
|
+
|
|
121
|
+
### Setup
|
|
122
|
+
```bash
|
|
123
|
+
cd mxcpctl
|
|
124
|
+
uv sync --extra dev
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Run from Source
|
|
128
|
+
```bash
|
|
129
|
+
uv run mxcpctl --help
|
|
130
|
+
uv run mxcpctl status
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Testing
|
|
134
|
+
```bash
|
|
135
|
+
uv run pytest
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Build Package
|
|
139
|
+
```bash
|
|
140
|
+
uv run python -m build
|
|
141
|
+
uv run twine check dist/*
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Releasing
|
|
145
|
+
|
|
146
|
+
See top-level README for release instructions. Use `./release.sh` script which updates versions for all components.
|
|
147
|
+
|
|
148
|
+
## Tips
|
|
149
|
+
|
|
150
|
+
### Save Connection Config
|
|
151
|
+
|
|
152
|
+
Create a shell alias:
|
|
153
|
+
```bash
|
|
154
|
+
alias mxcpctl-prod='mxcpctl --host mxcpd.prod.example.com --token $PROD_TOKEN'
|
|
155
|
+
mxcpctl-prod status
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Multiple Environments
|
|
159
|
+
|
|
160
|
+
Use different environment variable files:
|
|
161
|
+
```bash
|
|
162
|
+
# .env.staging
|
|
163
|
+
export MXCPCTL_HOST=mxcpd-staging.example.com
|
|
164
|
+
export MXCPCTL_TOKEN=staging-token
|
|
165
|
+
|
|
166
|
+
# .env.production
|
|
167
|
+
export MXCPCTL_HOST=mxcpd.example.com
|
|
168
|
+
export MXCPCTL_TOKEN=prod-token
|
|
169
|
+
|
|
170
|
+
# Use with:
|
|
171
|
+
source .env.staging && mxcpctl status
|
|
172
|
+
source .env.production && mxcpctl status
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### JSON Output
|
|
176
|
+
|
|
177
|
+
For scripting, pipe output through `jq` after making API calls:
|
|
178
|
+
```bash
|
|
179
|
+
curl -H "Authorization: Bearer $TOKEN" \
|
|
180
|
+
http://mxcpd:8080/api/v1/status | jq
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
(Direct JSON output support could be added to mxcpctl in the future)
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
Copyright © 2025 RAW Labs SA. All rights reserved.
|
|
188
|
+
|
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
"""CLI commands for mxcpctl.
|
|
2
|
+
|
|
3
|
+
This module provides the main command-line interface for managing
|
|
4
|
+
MXCP instances through the mxcpd API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import httpx
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_headers(token: str | None) -> dict[str, str]:
|
|
17
|
+
"""Get HTTP headers with optional authentication token.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
token: Optional API token for Bearer authentication
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Dictionary of HTTP headers
|
|
24
|
+
"""
|
|
25
|
+
headers = {}
|
|
26
|
+
if token:
|
|
27
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
28
|
+
return headers
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@click.group()
|
|
32
|
+
@click.option("--host", default="localhost", envvar="MXCPCTL_HOST", help="mxcpd host")
|
|
33
|
+
@click.option("--port", default=8000, envvar="MXCPCTL_PORT", help="mxcpd port")
|
|
34
|
+
@click.option("--tls/--no-tls", default=False, help="Use HTTPS")
|
|
35
|
+
@click.option("--token", envvar="MXCPCTL_TOKEN", help="API token for authentication")
|
|
36
|
+
@click.version_option(version="0.1.0")
|
|
37
|
+
@click.pass_context
|
|
38
|
+
def main(ctx: click.Context, host: str, port: int, tls: bool, token: str | None) -> None:
|
|
39
|
+
"""mxcpctl - MXCP instance management CLI.
|
|
40
|
+
|
|
41
|
+
Communicates with mxcpd to monitor and control MXCP instances.
|
|
42
|
+
"""
|
|
43
|
+
# Store connection info in context
|
|
44
|
+
ctx.ensure_object(dict)
|
|
45
|
+
ctx.obj["host"] = host
|
|
46
|
+
ctx.obj["port"] = port
|
|
47
|
+
ctx.obj["base_url"] = f"{'https' if tls else 'http'}://{host}:{port}"
|
|
48
|
+
ctx.obj["token"] = token
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@main.command()
|
|
52
|
+
@click.pass_context
|
|
53
|
+
def health(ctx: click.Context) -> None:
|
|
54
|
+
"""Check mxcpd health."""
|
|
55
|
+
base_url = ctx.obj["base_url"]
|
|
56
|
+
token = ctx.obj.get("token")
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
response = httpx.get(f"{base_url}/health", headers=get_headers(token), timeout=5.0)
|
|
60
|
+
response.raise_for_status()
|
|
61
|
+
data = response.json()
|
|
62
|
+
|
|
63
|
+
console.print("[green]✓[/green] mxcpd is healthy")
|
|
64
|
+
console.print(f" Version: {data['version']}")
|
|
65
|
+
console.print(f" Instance: {data['instance']}")
|
|
66
|
+
console.print(f" Environment: {data['environment']}")
|
|
67
|
+
console.print(f" Status: {data['status']}")
|
|
68
|
+
|
|
69
|
+
except httpx.ConnectError:
|
|
70
|
+
console.print(f"[red]✗[/red] Failed to connect to {base_url}")
|
|
71
|
+
console.print(" Is mxcpd running?")
|
|
72
|
+
sys.exit(1)
|
|
73
|
+
except httpx.TimeoutException:
|
|
74
|
+
console.print("[red]✗[/red] Connection timeout")
|
|
75
|
+
sys.exit(1)
|
|
76
|
+
except httpx.HTTPStatusError as e:
|
|
77
|
+
console.print(f"[red]✗[/red] HTTP error: {e.response.status_code}")
|
|
78
|
+
sys.exit(1)
|
|
79
|
+
except Exception as e:
|
|
80
|
+
console.print(f"[red]✗[/red] Unexpected error: {e}")
|
|
81
|
+
sys.exit(1)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@main.command()
|
|
85
|
+
@click.option("--instance", "-i", help="Filter by instance ID")
|
|
86
|
+
@click.pass_context
|
|
87
|
+
def status(ctx: click.Context, instance: str | None) -> None:
|
|
88
|
+
"""Get instance status."""
|
|
89
|
+
base_url = ctx.obj["base_url"]
|
|
90
|
+
token = ctx.obj.get("token")
|
|
91
|
+
params = {"instance": instance} if instance else {}
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
response = httpx.get(
|
|
95
|
+
f"{base_url}/api/v1/status", params=params, headers=get_headers(token), timeout=10.0
|
|
96
|
+
)
|
|
97
|
+
response.raise_for_status()
|
|
98
|
+
data = response.json()
|
|
99
|
+
|
|
100
|
+
# Multi-instance response is a list
|
|
101
|
+
for instance_data in data:
|
|
102
|
+
console.print(
|
|
103
|
+
f"\n[cyan]Instance: {instance_data['instance_name']}[/cyan] ({instance_data['instance_id']})" # noqa: E501
|
|
104
|
+
)
|
|
105
|
+
console.print(f" Status: [green]{instance_data.get('status', 'unknown')}[/green]")
|
|
106
|
+
console.print(f" Version: {instance_data.get('version', 'unknown')}")
|
|
107
|
+
console.print(f" Uptime: {instance_data.get('uptime', 'unknown')}")
|
|
108
|
+
console.print(f" Profile: {instance_data.get('profile', 'unknown')}")
|
|
109
|
+
console.print(f" Mode: {instance_data.get('mode', 'unknown')}")
|
|
110
|
+
|
|
111
|
+
if instance_data.get("error"):
|
|
112
|
+
console.print(f" [red]Error: {instance_data['error']}[/red]")
|
|
113
|
+
|
|
114
|
+
except httpx.RequestError as e:
|
|
115
|
+
console.print(f"[red]✗[/red] Request failed: {e}")
|
|
116
|
+
sys.exit(1)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@main.command()
|
|
120
|
+
@click.option("--instance", "-i", help="Filter by instance ID")
|
|
121
|
+
@click.pass_context
|
|
122
|
+
def config(ctx: click.Context, instance: str | None) -> None:
|
|
123
|
+
"""Get instance configuration."""
|
|
124
|
+
base_url = ctx.obj["base_url"]
|
|
125
|
+
token = ctx.obj.get("token")
|
|
126
|
+
params = {"instance": instance} if instance else {}
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
response = httpx.get(
|
|
130
|
+
f"{base_url}/api/v1/config", params=params, headers=get_headers(token), timeout=10.0
|
|
131
|
+
)
|
|
132
|
+
response.raise_for_status()
|
|
133
|
+
data = response.json()
|
|
134
|
+
|
|
135
|
+
for config_data in data:
|
|
136
|
+
console.print(
|
|
137
|
+
f"\n[cyan]Instance: {config_data['instance_name']}[/cyan] ({config_data['instance_id']})" # noqa: E501
|
|
138
|
+
)
|
|
139
|
+
console.print(f" Profile: {config_data.get('profile', 'N/A')}")
|
|
140
|
+
console.print(f" Environment: {config_data.get('environment', 'N/A')}")
|
|
141
|
+
console.print(f" Read-only: {config_data.get('readonly', False)}")
|
|
142
|
+
console.print(f" Debug: {config_data.get('debug', False)}")
|
|
143
|
+
|
|
144
|
+
if config_data.get("features"):
|
|
145
|
+
features = config_data["features"]
|
|
146
|
+
console.print(" Features:")
|
|
147
|
+
console.print(f" SQL Tools: {features.get('sql_tools', False)}")
|
|
148
|
+
console.print(f" Audit Logging: {features.get('audit_logging', False)}")
|
|
149
|
+
console.print(f" Telemetry: {features.get('telemetry', False)}")
|
|
150
|
+
|
|
151
|
+
except httpx.RequestError as e:
|
|
152
|
+
console.print(f"[red]✗[/red] Request failed: {e}")
|
|
153
|
+
sys.exit(1)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@main.command()
|
|
157
|
+
@click.option("--instance", "-i", help="Filter by instance ID")
|
|
158
|
+
@click.pass_context
|
|
159
|
+
def reload(ctx: click.Context, instance: str | None) -> None:
|
|
160
|
+
"""Trigger configuration reload."""
|
|
161
|
+
base_url = ctx.obj["base_url"]
|
|
162
|
+
token = ctx.obj.get("token")
|
|
163
|
+
params = {"instance": instance} if instance else {}
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
response = httpx.post(
|
|
167
|
+
f"{base_url}/api/v1/reload", params=params, headers=get_headers(token), timeout=30.0
|
|
168
|
+
)
|
|
169
|
+
response.raise_for_status()
|
|
170
|
+
data = response.json()
|
|
171
|
+
|
|
172
|
+
for reload_data in data:
|
|
173
|
+
instance_name = reload_data.get("instance_name", "unknown")
|
|
174
|
+
status = reload_data.get("status", "unknown")
|
|
175
|
+
|
|
176
|
+
if status == "success":
|
|
177
|
+
console.print(f"[green]✓[/green] Reload triggered for {instance_name}")
|
|
178
|
+
if reload_data.get("request_id"):
|
|
179
|
+
console.print(f" Request ID: {reload_data['request_id']}")
|
|
180
|
+
else:
|
|
181
|
+
console.print(f"[red]✗[/red] Reload failed for {instance_name}")
|
|
182
|
+
if reload_data.get("error"):
|
|
183
|
+
console.print(f" Error: {reload_data['error']}")
|
|
184
|
+
|
|
185
|
+
except httpx.RequestError as e:
|
|
186
|
+
console.print(f"[red]✗[/red] Request failed: {e}")
|
|
187
|
+
sys.exit(1)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@main.group()
|
|
191
|
+
def endpoints() -> None:
|
|
192
|
+
"""Manage endpoints."""
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@endpoints.command("list")
|
|
197
|
+
@click.option("--instance", "-i", help="Filter by instance ID")
|
|
198
|
+
@click.option(
|
|
199
|
+
"--type",
|
|
200
|
+
"-t",
|
|
201
|
+
type=click.Choice(["tool", "resource", "prompt"]),
|
|
202
|
+
help="Filter by endpoint type",
|
|
203
|
+
)
|
|
204
|
+
@click.pass_context
|
|
205
|
+
def list_endpoints(ctx: click.Context, instance: str | None, type: str | None) -> None:
|
|
206
|
+
"""List all endpoints."""
|
|
207
|
+
base_url = ctx.obj["base_url"]
|
|
208
|
+
token = ctx.obj.get("token")
|
|
209
|
+
params = {}
|
|
210
|
+
if instance:
|
|
211
|
+
params["instance"] = instance
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
response = httpx.get(
|
|
215
|
+
f"{base_url}/api/v1/endpoints", params=params, headers=get_headers(token), timeout=10.0
|
|
216
|
+
)
|
|
217
|
+
response.raise_for_status()
|
|
218
|
+
data = response.json()
|
|
219
|
+
|
|
220
|
+
for instance_data in data:
|
|
221
|
+
console.print(
|
|
222
|
+
f"\n[cyan]Instance: {instance_data['instance_name']}[/cyan] ({instance_data['instance_id']})" # noqa: E501
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
endpoints_list = instance_data.get("endpoints", [])
|
|
226
|
+
|
|
227
|
+
# Filter by type if specified
|
|
228
|
+
if type:
|
|
229
|
+
endpoints_list = [ep for ep in endpoints_list if ep.get("type") == type]
|
|
230
|
+
|
|
231
|
+
if not endpoints_list:
|
|
232
|
+
console.print(" No endpoints found")
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
# Group by type
|
|
236
|
+
by_type: dict[str, list] = {}
|
|
237
|
+
for ep in endpoints_list:
|
|
238
|
+
ep_type = ep.get("type", "unknown")
|
|
239
|
+
if ep_type not in by_type:
|
|
240
|
+
by_type[ep_type] = []
|
|
241
|
+
by_type[ep_type].append(ep)
|
|
242
|
+
|
|
243
|
+
for ep_type, eps in by_type.items():
|
|
244
|
+
console.print(f"\n [yellow]{ep_type.upper()}S[/yellow] ({len(eps)})")
|
|
245
|
+
for ep in eps:
|
|
246
|
+
name = ep.get("name", "unknown")
|
|
247
|
+
enabled = ep.get("enabled", False)
|
|
248
|
+
status = ep.get("status", "unknown")
|
|
249
|
+
|
|
250
|
+
status_icon = "✓" if status == "ok" else "✗"
|
|
251
|
+
status_color = "green" if status == "ok" else "red"
|
|
252
|
+
|
|
253
|
+
console.print(f" [{status_color}]{status_icon}[/{status_color}] {name}")
|
|
254
|
+
if ep.get("description"):
|
|
255
|
+
console.print(f" {ep['description']}")
|
|
256
|
+
if not enabled:
|
|
257
|
+
console.print(" [dim](disabled)[/dim]")
|
|
258
|
+
if ep.get("error"):
|
|
259
|
+
console.print(f" [red]Error: {ep['error']}[/red]")
|
|
260
|
+
|
|
261
|
+
except httpx.RequestError as e:
|
|
262
|
+
console.print(f"[red]✗[/red] Request failed: {e}")
|
|
263
|
+
sys.exit(1)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@main.group()
|
|
267
|
+
def audit() -> None:
|
|
268
|
+
"""Query audit logs."""
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@audit.command("query")
|
|
273
|
+
@click.option("--instance", "-i", help="Filter by instance ID")
|
|
274
|
+
@click.option("--operation-type", help="Filter by operation type (tool, resource, prompt)")
|
|
275
|
+
@click.option("--operation-name", help="Filter by operation name")
|
|
276
|
+
@click.option("--status", type=click.Choice(["success", "error"]), help="Filter by status")
|
|
277
|
+
@click.option("--user-id", help="Filter by user ID")
|
|
278
|
+
@click.option("--limit", default=10, help="Maximum number of records (default: 10)")
|
|
279
|
+
@click.option("--offset", default=0, help="Number of records to skip")
|
|
280
|
+
@click.pass_context
|
|
281
|
+
def query_audit(
|
|
282
|
+
ctx: click.Context,
|
|
283
|
+
instance: str | None,
|
|
284
|
+
operation_type: str | None,
|
|
285
|
+
operation_name: str | None,
|
|
286
|
+
status: str | None,
|
|
287
|
+
user_id: str | None,
|
|
288
|
+
limit: int,
|
|
289
|
+
offset: int,
|
|
290
|
+
) -> None:
|
|
291
|
+
"""Query audit logs with filters."""
|
|
292
|
+
base_url = ctx.obj["base_url"]
|
|
293
|
+
token = ctx.obj.get("token")
|
|
294
|
+
params: dict[str, str | int] = {"limit": limit, "offset": offset}
|
|
295
|
+
|
|
296
|
+
if instance:
|
|
297
|
+
params["instance"] = instance
|
|
298
|
+
if operation_type:
|
|
299
|
+
params["operation_type"] = operation_type
|
|
300
|
+
if operation_name:
|
|
301
|
+
params["operation_name"] = operation_name
|
|
302
|
+
if status:
|
|
303
|
+
params["operation_status"] = status
|
|
304
|
+
if user_id:
|
|
305
|
+
params["user_id"] = user_id
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
response = httpx.get(
|
|
309
|
+
f"{base_url}/api/v1/audit/query",
|
|
310
|
+
params=params,
|
|
311
|
+
headers=get_headers(token),
|
|
312
|
+
timeout=30.0,
|
|
313
|
+
)
|
|
314
|
+
response.raise_for_status()
|
|
315
|
+
data = response.json()
|
|
316
|
+
|
|
317
|
+
for instance_data in data:
|
|
318
|
+
console.print(
|
|
319
|
+
f"\n[cyan]Instance: {instance_data['instance_name']}[/cyan] ({instance_data['instance_id']})" # noqa: E501
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
records = instance_data.get("records", [])
|
|
323
|
+
count = instance_data.get("count", 0)
|
|
324
|
+
|
|
325
|
+
console.print(f" Found {count} record(s)")
|
|
326
|
+
|
|
327
|
+
if not records:
|
|
328
|
+
continue
|
|
329
|
+
|
|
330
|
+
for record in records:
|
|
331
|
+
timestamp = record.get("timestamp", "N/A")
|
|
332
|
+
op_type = record.get("operation_type", "N/A")
|
|
333
|
+
op_name = record.get("operation_name", "N/A")
|
|
334
|
+
op_status = record.get("operation_status", "N/A")
|
|
335
|
+
duration = record.get("duration_ms")
|
|
336
|
+
|
|
337
|
+
status_icon = "✓" if op_status == "success" else "✗"
|
|
338
|
+
status_color = "green" if op_status == "success" else "red"
|
|
339
|
+
|
|
340
|
+
console.print(
|
|
341
|
+
f"\n [{status_color}]{status_icon}[/{status_color}] {op_type}/{op_name}"
|
|
342
|
+
)
|
|
343
|
+
console.print(f" Time: {timestamp}")
|
|
344
|
+
console.print(f" Status: {op_status}")
|
|
345
|
+
if duration is not None:
|
|
346
|
+
console.print(f" Duration: {duration}ms")
|
|
347
|
+
if record.get("user_id"):
|
|
348
|
+
console.print(f" User: {record['user_id']}")
|
|
349
|
+
if record.get("error_message"):
|
|
350
|
+
console.print(f" [red]Error: {record['error_message']}[/red]")
|
|
351
|
+
|
|
352
|
+
except httpx.RequestError as e:
|
|
353
|
+
console.print(f"[red]✗[/red] Request failed: {e}")
|
|
354
|
+
sys.exit(1)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@audit.command("stats")
|
|
358
|
+
@click.option("--instance", "-i", help="Filter by instance ID")
|
|
359
|
+
@click.pass_context
|
|
360
|
+
def audit_stats(ctx: click.Context, instance: str | None) -> None:
|
|
361
|
+
"""Get audit log statistics."""
|
|
362
|
+
base_url = ctx.obj["base_url"]
|
|
363
|
+
token = ctx.obj.get("token")
|
|
364
|
+
params = {"instance": instance} if instance else {}
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
response = httpx.get(
|
|
368
|
+
f"{base_url}/api/v1/audit/stats",
|
|
369
|
+
params=params,
|
|
370
|
+
headers=get_headers(token),
|
|
371
|
+
timeout=10.0,
|
|
372
|
+
)
|
|
373
|
+
response.raise_for_status()
|
|
374
|
+
data = response.json()
|
|
375
|
+
|
|
376
|
+
for instance_data in data:
|
|
377
|
+
console.print(
|
|
378
|
+
f"\n[cyan]Instance: {instance_data['instance_name']}[/cyan] ({instance_data['instance_id']})" # noqa: E501
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
total = instance_data.get("total_records", 0)
|
|
382
|
+
console.print(f" Total Records: {total}")
|
|
383
|
+
|
|
384
|
+
if instance_data.get("by_type"):
|
|
385
|
+
console.print("\n By Type:")
|
|
386
|
+
for op_type, count in instance_data["by_type"].items():
|
|
387
|
+
console.print(f" {op_type}: {count}")
|
|
388
|
+
|
|
389
|
+
if instance_data.get("by_status"):
|
|
390
|
+
console.print("\n By Status:")
|
|
391
|
+
for status, count in instance_data["by_status"].items():
|
|
392
|
+
status_color = "green" if status == "success" else "red"
|
|
393
|
+
console.print(f" [{status_color}]{status}[/{status_color}]: {count}")
|
|
394
|
+
|
|
395
|
+
if instance_data.get("earliest_timestamp"):
|
|
396
|
+
console.print("\n Time Range:")
|
|
397
|
+
console.print(f" Earliest: {instance_data['earliest_timestamp']}")
|
|
398
|
+
console.print(f" Latest: {instance_data.get('latest_timestamp', 'N/A')}")
|
|
399
|
+
|
|
400
|
+
except httpx.RequestError as e:
|
|
401
|
+
console.print(f"[red]✗[/red] Request failed: {e}")
|
|
402
|
+
sys.exit(1)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@main.group()
|
|
406
|
+
def telemetry() -> None:
|
|
407
|
+
"""Query telemetry data (traces and metrics)."""
|
|
408
|
+
pass
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@telemetry.command("status")
|
|
412
|
+
@click.pass_context
|
|
413
|
+
def telemetry_status(ctx: click.Context) -> None:
|
|
414
|
+
"""Get telemetry receiver status."""
|
|
415
|
+
base_url = ctx.obj["base_url"]
|
|
416
|
+
token = ctx.obj.get("token")
|
|
417
|
+
|
|
418
|
+
try:
|
|
419
|
+
response = httpx.get(
|
|
420
|
+
f"{base_url}/api/v1/telemetry/status", headers=get_headers(token), timeout=10.0
|
|
421
|
+
)
|
|
422
|
+
response.raise_for_status()
|
|
423
|
+
result = response.json()
|
|
424
|
+
data = result.get("data", {})
|
|
425
|
+
|
|
426
|
+
console.print("\n[cyan]Telemetry Receiver Status[/cyan]")
|
|
427
|
+
console.print(f" Enabled: {'Yes' if data.get('enabled') else 'No'}")
|
|
428
|
+
console.print(f" Traces Received: {data.get('traces_received', 0)}")
|
|
429
|
+
console.print(f" Traces Stored: {data.get('traces_stored', 0)}")
|
|
430
|
+
console.print(f" Metrics Received: {data.get('metrics_received', 0)}")
|
|
431
|
+
console.print(f" Storage Usage: {data.get('storage_usage_mb', 0):.2f} MB")
|
|
432
|
+
|
|
433
|
+
if data.get("last_trace_time"):
|
|
434
|
+
console.print(f" Last Trace: {data['last_trace_time']}")
|
|
435
|
+
|
|
436
|
+
except httpx.RequestError as e:
|
|
437
|
+
console.print(f"[red]✗[/red] Request failed: {e}")
|
|
438
|
+
sys.exit(1)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
@telemetry.command("traces")
|
|
442
|
+
@click.option("--limit", default=20, help="Maximum number of traces to show")
|
|
443
|
+
@click.option("--endpoint", help="Filter by endpoint name")
|
|
444
|
+
@click.pass_context
|
|
445
|
+
def list_traces(ctx: click.Context, limit: int, endpoint: str | None) -> None:
|
|
446
|
+
"""List recent traces."""
|
|
447
|
+
base_url = ctx.obj["base_url"]
|
|
448
|
+
token = ctx.obj.get("token")
|
|
449
|
+
params: dict[str, str | int] = {"limit": limit}
|
|
450
|
+
|
|
451
|
+
if endpoint:
|
|
452
|
+
params["endpoint_name"] = endpoint
|
|
453
|
+
|
|
454
|
+
try:
|
|
455
|
+
response = httpx.get(
|
|
456
|
+
f"{base_url}/api/v1/telemetry/traces",
|
|
457
|
+
params=params,
|
|
458
|
+
headers=get_headers(token),
|
|
459
|
+
timeout=10.0,
|
|
460
|
+
)
|
|
461
|
+
response.raise_for_status()
|
|
462
|
+
result = response.json()
|
|
463
|
+
data = result.get("data", {})
|
|
464
|
+
traces = data.get("items", [])
|
|
465
|
+
|
|
466
|
+
if not traces:
|
|
467
|
+
console.print("No traces found")
|
|
468
|
+
return
|
|
469
|
+
|
|
470
|
+
console.print(f"\n[cyan]Recent Traces[/cyan] (showing {len(traces)})")
|
|
471
|
+
|
|
472
|
+
for trace in traces:
|
|
473
|
+
trace_id = trace.get("trace_id", "N/A")
|
|
474
|
+
endpoint_name = trace.get("endpoint_name", "N/A")
|
|
475
|
+
duration = trace.get("duration_ms", 0)
|
|
476
|
+
status = trace.get("status", "unknown")
|
|
477
|
+
span_count = trace.get("span_count", 0)
|
|
478
|
+
|
|
479
|
+
status_icon = "✓" if status == "ok" else "✗"
|
|
480
|
+
status_color = "green" if status == "ok" else "red"
|
|
481
|
+
|
|
482
|
+
console.print(f"\n [{status_color}]{status_icon}[/{status_color}] {endpoint_name}")
|
|
483
|
+
console.print(f" Trace ID: {trace_id}")
|
|
484
|
+
console.print(f" Duration: {duration:.2f}ms")
|
|
485
|
+
console.print(f" Spans: {span_count}")
|
|
486
|
+
console.print(f" Time: {trace.get('start_time', 'N/A')}")
|
|
487
|
+
|
|
488
|
+
except httpx.RequestError as e:
|
|
489
|
+
console.print(f"[red]✗[/red] Request failed: {e}")
|
|
490
|
+
sys.exit(1)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@telemetry.command("trace")
|
|
494
|
+
@click.argument("trace_id")
|
|
495
|
+
@click.pass_context
|
|
496
|
+
def get_trace(ctx: click.Context, trace_id: str) -> None:
|
|
497
|
+
"""Get detailed trace information."""
|
|
498
|
+
base_url = ctx.obj["base_url"]
|
|
499
|
+
token = ctx.obj.get("token")
|
|
500
|
+
|
|
501
|
+
try:
|
|
502
|
+
response = httpx.get(
|
|
503
|
+
f"{base_url}/api/v1/telemetry/traces/{trace_id}",
|
|
504
|
+
headers=get_headers(token),
|
|
505
|
+
timeout=10.0,
|
|
506
|
+
)
|
|
507
|
+
response.raise_for_status()
|
|
508
|
+
result = response.json()
|
|
509
|
+
trace = result.get("data", {})
|
|
510
|
+
|
|
511
|
+
console.print("\n[cyan]Trace Details[/cyan]")
|
|
512
|
+
console.print(f" Trace ID: {trace.get('trace_id', 'N/A')}")
|
|
513
|
+
console.print(f" Endpoint: {trace.get('endpoint_name', 'N/A')}")
|
|
514
|
+
console.print(f" Service: {trace.get('service_name', 'N/A')}")
|
|
515
|
+
console.print(f" Duration: {trace.get('duration_ms', 0):.2f}ms")
|
|
516
|
+
console.print(f" Status: {trace.get('status', 'unknown')}")
|
|
517
|
+
console.print(f" Span Count: {trace.get('span_count', 0)}")
|
|
518
|
+
|
|
519
|
+
spans = trace.get("spans", [])
|
|
520
|
+
if spans:
|
|
521
|
+
console.print("\n [yellow]Spans[/yellow]:")
|
|
522
|
+
for span in spans:
|
|
523
|
+
indent = " " if span.get("parent_span_id") else " "
|
|
524
|
+
name = span.get("name", "N/A")
|
|
525
|
+
duration = span.get("duration_ms", 0)
|
|
526
|
+
console.print(f"{indent}• {name} ({duration:.2f}ms)")
|
|
527
|
+
|
|
528
|
+
except httpx.HTTPStatusError as e:
|
|
529
|
+
if e.response.status_code == 404:
|
|
530
|
+
console.print(f"[red]✗[/red] Trace not found: {trace_id}")
|
|
531
|
+
else:
|
|
532
|
+
console.print(f"[red]✗[/red] HTTP error: {e.response.status_code}")
|
|
533
|
+
sys.exit(1)
|
|
534
|
+
except httpx.RequestError as e:
|
|
535
|
+
console.print(f"[red]✗[/red] Request failed: {e}")
|
|
536
|
+
sys.exit(1)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
@telemetry.command("metrics")
|
|
540
|
+
@click.option("--endpoint", help="Filter by endpoint name")
|
|
541
|
+
@click.option("--window", type=int, help="Time window in hours (1-24)")
|
|
542
|
+
@click.pass_context
|
|
543
|
+
def get_metrics(ctx: click.Context, endpoint: str | None, window: int | None) -> None:
|
|
544
|
+
"""Get aggregated performance metrics."""
|
|
545
|
+
base_url = ctx.obj["base_url"]
|
|
546
|
+
token = ctx.obj.get("token")
|
|
547
|
+
params: dict[str, str | int] = {}
|
|
548
|
+
|
|
549
|
+
if endpoint:
|
|
550
|
+
params["endpoint_name"] = endpoint
|
|
551
|
+
if window:
|
|
552
|
+
params["window_hours"] = window
|
|
553
|
+
|
|
554
|
+
try:
|
|
555
|
+
response = httpx.get(
|
|
556
|
+
f"{base_url}/api/v1/telemetry/metrics",
|
|
557
|
+
params=params,
|
|
558
|
+
headers=get_headers(token),
|
|
559
|
+
timeout=10.0,
|
|
560
|
+
)
|
|
561
|
+
response.raise_for_status()
|
|
562
|
+
result = response.json()
|
|
563
|
+
data = result.get("data", {})
|
|
564
|
+
metrics = data.get("metrics", [])
|
|
565
|
+
|
|
566
|
+
if not metrics:
|
|
567
|
+
console.print("No metrics found")
|
|
568
|
+
return
|
|
569
|
+
|
|
570
|
+
window_str = f"{window}h" if window else "all time"
|
|
571
|
+
console.print(f"\n[cyan]Performance Metrics[/cyan] ({window_str})")
|
|
572
|
+
|
|
573
|
+
for metric in metrics:
|
|
574
|
+
endpoint_name = metric.get("endpoint_name", "N/A")
|
|
575
|
+
requests = metric.get("request_count", 0)
|
|
576
|
+
errors = metric.get("error_count", 0)
|
|
577
|
+
error_rate = metric.get("error_rate", 0) * 100
|
|
578
|
+
|
|
579
|
+
console.print(f"\n [yellow]{endpoint_name}[/yellow]")
|
|
580
|
+
console.print(f" Requests: {requests}")
|
|
581
|
+
console.print(f" Errors: {errors} ({error_rate:.1f}%)")
|
|
582
|
+
|
|
583
|
+
if metric.get("p50_ms") is not None:
|
|
584
|
+
console.print(" Latency:")
|
|
585
|
+
console.print(f" P50: {metric['p50_ms']:.2f}ms")
|
|
586
|
+
console.print(f" P95: {metric.get('p95_ms', 0):.2f}ms")
|
|
587
|
+
console.print(f" P99: {metric.get('p99_ms', 0):.2f}ms")
|
|
588
|
+
console.print(f" Avg: {metric.get('avg_ms', 0):.2f}ms")
|
|
589
|
+
|
|
590
|
+
except httpx.RequestError as e:
|
|
591
|
+
console.print(f"[red]✗[/red] Request failed: {e}")
|
|
592
|
+
sys.exit(1)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
if __name__ == "__main__":
|
|
596
|
+
main()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mxcpctl
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: CLI tool for managing MXCP instances via mxcpd
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: click>=8.1.0
|
|
7
|
+
Requires-Dist: httpx>=0.25.0
|
|
8
|
+
Requires-Dist: rich>=13.0.0
|
|
9
|
+
Requires-Dist: pyyaml>=6.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=7.4.0; extra == "dev"
|
|
12
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
13
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
14
|
+
Requires-Dist: mypy>=1.6.0; extra == "dev"
|
|
15
|
+
Requires-Dist: build>=1.0.0; extra == "dev"
|
|
16
|
+
Requires-Dist: twine>=4.0.0; extra == "dev"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
mxcpctl/__init__.py
|
|
4
|
+
mxcpctl/cli.py
|
|
5
|
+
mxcpctl.egg-info/PKG-INFO
|
|
6
|
+
mxcpctl.egg-info/SOURCES.txt
|
|
7
|
+
mxcpctl.egg-info/dependency_links.txt
|
|
8
|
+
mxcpctl.egg-info/entry_points.txt
|
|
9
|
+
mxcpctl.egg-info/requires.txt
|
|
10
|
+
mxcpctl.egg-info/top_level.txt
|
|
11
|
+
tests/test_cli.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mxcpctl
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mxcpctl"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "CLI tool for managing MXCP instances via mxcpd"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"click>=8.1.0",
|
|
12
|
+
"httpx>=0.25.0",
|
|
13
|
+
"rich>=13.0.0",
|
|
14
|
+
"pyyaml>=6.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
dev = [
|
|
19
|
+
"pytest>=7.4.0",
|
|
20
|
+
"black>=23.0.0",
|
|
21
|
+
"ruff>=0.1.0",
|
|
22
|
+
"mypy>=1.6.0",
|
|
23
|
+
"build>=1.0.0",
|
|
24
|
+
"twine>=4.0.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
mxcpctl = "mxcpctl.cli:main"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["."]
|
|
32
|
+
include = ["mxcpctl*"]
|
|
33
|
+
|
|
34
|
+
# Black configuration
|
|
35
|
+
[tool.black]
|
|
36
|
+
line-length = 100
|
|
37
|
+
target-version = ["py311"]
|
|
38
|
+
|
|
39
|
+
# Ruff configuration
|
|
40
|
+
[tool.ruff]
|
|
41
|
+
line-length = 100
|
|
42
|
+
target-version = "py311"
|
|
43
|
+
|
|
44
|
+
[tool.ruff.lint]
|
|
45
|
+
select = ["E", "F", "I", "N", "W", "UP"]
|
|
46
|
+
|
|
47
|
+
[tool.ruff.lint.isort]
|
|
48
|
+
known-first-party = ["mxcpctl"]
|
|
49
|
+
|
|
50
|
+
# Mypy configuration
|
|
51
|
+
[tool.mypy]
|
|
52
|
+
python_version = "3.11"
|
|
53
|
+
warn_return_any = true
|
|
54
|
+
warn_unused_configs = true
|
|
55
|
+
|
|
56
|
+
# Pytest configuration
|
|
57
|
+
[tool.pytest.ini_options]
|
|
58
|
+
testpaths = ["tests"]
|
|
59
|
+
python_files = ["test_*.py"]
|
|
60
|
+
python_classes = ["Test*"]
|
|
61
|
+
python_functions = ["test_*"]
|
|
62
|
+
|
mxcpctl-0.0.1/setup.cfg
ADDED