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 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"
@@ -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,3 @@
1
+ """mxcpctl - CLI tool for MXCP instance management."""
2
+
3
+ __version__ = "0.1.0"
@@ -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,2 @@
1
+ [console_scripts]
2
+ mxcpctl = mxcpctl.cli:main
@@ -0,0 +1,12 @@
1
+ click>=8.1.0
2
+ httpx>=0.25.0
3
+ rich>=13.0.0
4
+ pyyaml>=6.0
5
+
6
+ [dev]
7
+ pytest>=7.4.0
8
+ black>=23.0.0
9
+ ruff>=0.1.0
10
+ mypy>=1.6.0
11
+ build>=1.0.0
12
+ twine>=4.0.0
@@ -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
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,9 @@
1
+ """Tests for mxcpctl CLI.
2
+
3
+ TODO: Add comprehensive tests for CLI commands.
4
+ """
5
+
6
+
7
+ def test_placeholder():
8
+ """Placeholder test until real tests are implemented."""
9
+ assert True