PyStreamMCP 0.3.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.
@@ -0,0 +1,24 @@
1
+ """PyStreamMCP - Intelligence Layer for AI Agents."""
2
+
3
+ __version__ = "0.3.0"
4
+ __author__ = "Georgi Mammen Mullassery"
5
+
6
+ from .core import (
7
+ MetadataIndex,
8
+ CapabilityDiscovery,
9
+ SchemaExtractor,
10
+ RelationshipGraph,
11
+ Metadata,
12
+ Capability,
13
+ Schema,
14
+ )
15
+
16
+ __all__ = [
17
+ "MetadataIndex",
18
+ "CapabilityDiscovery",
19
+ "SchemaExtractor",
20
+ "RelationshipGraph",
21
+ "Metadata",
22
+ "Capability",
23
+ "Schema",
24
+ ]
pystreammcp/api.py ADDED
@@ -0,0 +1,273 @@
1
+ """REST API for PyStreamMCP v0.3."""
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ try:
9
+ from flask import Flask, jsonify, request, send_file
10
+ from flask_cors import CORS
11
+ HAS_FLASK = True
12
+ except ImportError:
13
+ HAS_FLASK = False
14
+
15
+ from pystreammcp.core import (
16
+ MetadataIndex,
17
+ CapabilityClassification,
18
+ Metadata,
19
+ )
20
+ from pystreammcp.persistence import SqlitePersistence
21
+ from pystreammcp.routing import SemanticRouter, RoutingPlanner
22
+
23
+
24
+ class StreamMCPAPI:
25
+ """REST API server for PyStreamMCP."""
26
+
27
+ def __init__(self, db_path: str = ":memory:", debug: bool = False):
28
+ """Initialize API."""
29
+ if not HAS_FLASK:
30
+ raise ImportError(
31
+ "Flask is required for the API. Install with: pip install flask flask-cors"
32
+ )
33
+
34
+ self.db_path = db_path
35
+ self.app = Flask(__name__)
36
+ self.app.config["JSON_SORT_KEYS"] = False
37
+ CORS(self.app)
38
+
39
+ self.index = MetadataIndex()
40
+ self.persistence = SqlitePersistence(db_path)
41
+ self.debug = debug
42
+
43
+ self._register_routes()
44
+
45
+ def _register_routes(self):
46
+ """Register API routes."""
47
+
48
+ @self.app.route("/health", methods=["GET"])
49
+ def health():
50
+ """Health check endpoint."""
51
+ return jsonify(
52
+ {
53
+ "status": "ok",
54
+ "version": "0.3.0",
55
+ "db": "sqlite" if self.db_path != ":memory:" else "in-memory",
56
+ }
57
+ )
58
+
59
+ @self.app.route("/api/v1/metadata", methods=["POST"])
60
+ def index_tool():
61
+ """Index a new tool."""
62
+ data = request.json
63
+ try:
64
+ name = data.get("name")
65
+ description = data.get("description")
66
+ server_id = data.get("server_id")
67
+ tool_id = data.get("tool_id")
68
+ input_schema = data.get("input_schema", {})
69
+ output_schema = data.get("output_schema", {})
70
+
71
+ if not all([name, server_id, tool_id]):
72
+ return jsonify({"error": "Missing required fields"}), 400
73
+
74
+ self.index.index_tool(
75
+ name=name,
76
+ description=description,
77
+ server_id=server_id,
78
+ tool_id=tool_id,
79
+ input_schema=input_schema,
80
+ output_schema=output_schema,
81
+ )
82
+
83
+ # Also persist to database
84
+ metadata = Metadata(
85
+ name=name,
86
+ description=description,
87
+ server_id=server_id,
88
+ tool_id=tool_id,
89
+ input_schema=input_schema,
90
+ output_schema=output_schema,
91
+ )
92
+ self.persistence.insert_metadata(metadata)
93
+
94
+ return jsonify(
95
+ {
96
+ "status": "success",
97
+ "message": f"Tool {name} indexed",
98
+ "metadata_id": metadata.id,
99
+ }
100
+ )
101
+ except Exception as e:
102
+ return jsonify({"error": str(e)}), 500
103
+
104
+ @self.app.route("/api/v1/search", methods=["GET"])
105
+ def search():
106
+ """Search for tools."""
107
+ query = request.args.get("q", "")
108
+ limit = int(request.args.get("limit", 10))
109
+
110
+ if not query:
111
+ return jsonify({"error": "Missing query parameter"}), 400
112
+
113
+ results = self.index.search_by_name(query)[:limit]
114
+
115
+ return jsonify(
116
+ {
117
+ "query": query,
118
+ "count": len(results),
119
+ "results": [
120
+ {
121
+ "id": m.id,
122
+ "name": m.name,
123
+ "description": m.description,
124
+ "server_id": m.server_id,
125
+ "tool_id": m.tool_id,
126
+ }
127
+ for m in results
128
+ ],
129
+ }
130
+ )
131
+
132
+ @self.app.route("/api/v1/route", methods=["POST"])
133
+ def route_query():
134
+ """Route a query to optimal tools."""
135
+ data = request.json
136
+ try:
137
+ query = data.get("query")
138
+ max_tools = int(data.get("max_tools", 3))
139
+
140
+ if not query:
141
+ return jsonify({"error": "Missing query parameter"}), 400
142
+
143
+ all_metadata = self.index.get_all_metadata()
144
+ capabilities_map = {}
145
+
146
+ for metadata in all_metadata:
147
+ caps = self.index.capability_discovery.get_by_metadata(
148
+ metadata.id
149
+ )
150
+ capabilities_map[metadata.id] = caps
151
+
152
+ scores = SemanticRouter.rank_tools(query, all_metadata, capabilities_map)
153
+ plan = RoutingPlanner.plan(query, scores, max_tools)
154
+
155
+ return jsonify(plan.to_dict())
156
+ except Exception as e:
157
+ return jsonify({"error": str(e)}), 500
158
+
159
+ @self.app.route("/api/v1/stats", methods=["GET"])
160
+ def stats():
161
+ """Get index statistics."""
162
+ stats_data = self.index.stats()
163
+ return jsonify(
164
+ {
165
+ "total_metadata": stats_data["total_metadata"],
166
+ "total_capabilities": stats_data["total_capabilities"],
167
+ "total_schemas": stats_data["total_schemas"],
168
+ "total_relationships": stats_data["total_relationships"],
169
+ "db_entries": self.persistence.count_metadata(),
170
+ }
171
+ )
172
+
173
+ @self.app.route("/api/v1/metadata/<metadata_id>", methods=["GET"])
174
+ def get_metadata(metadata_id):
175
+ """Get specific metadata."""
176
+ metadata = self.index.get_all_metadata()
177
+ for m in metadata:
178
+ if m.id == metadata_id or m.tool_id == metadata_id:
179
+ caps = self.index.capability_discovery.get_by_metadata(m.id)
180
+ return jsonify(
181
+ {
182
+ "id": m.id,
183
+ "name": m.name,
184
+ "description": m.description,
185
+ "server_id": m.server_id,
186
+ "tool_id": m.tool_id,
187
+ "capabilities": [
188
+ {
189
+ "classification": c.classification.value,
190
+ "inputs": c.inputs,
191
+ "outputs": c.outputs,
192
+ }
193
+ for c in caps
194
+ ],
195
+ }
196
+ )
197
+
198
+ return jsonify({"error": "Not found"}), 404
199
+
200
+ @self.app.route("/api/v1/capabilities", methods=["GET"])
201
+ def get_capabilities():
202
+ """Get capabilities by type."""
203
+ classification = request.args.get("type", "search")
204
+
205
+ try:
206
+ cap_class = CapabilityClassification[classification.title()]
207
+ caps = self.index.get_capabilities_by_classification(cap_class)
208
+
209
+ return jsonify(
210
+ {
211
+ "type": classification,
212
+ "count": len(caps),
213
+ "capabilities": [
214
+ {
215
+ "id": c.id,
216
+ "metadata_id": c.metadata_id,
217
+ "inputs": c.inputs,
218
+ "outputs": c.outputs,
219
+ "cost": c.cost_estimate,
220
+ }
221
+ for c in caps
222
+ ],
223
+ }
224
+ )
225
+ except KeyError:
226
+ return jsonify({"error": f"Unknown type: {classification}"}), 400
227
+
228
+ @self.app.route("/api/v1/servers", methods=["GET"])
229
+ def get_servers():
230
+ """Get all unique servers."""
231
+ servers = {}
232
+ for metadata in self.index.get_all_metadata():
233
+ if metadata.server_id not in servers:
234
+ servers[metadata.server_id] = 0
235
+ servers[metadata.server_id] += 1
236
+
237
+ return jsonify({"servers": servers})
238
+
239
+ @self.app.errorhandler(404)
240
+ def not_found(error):
241
+ """404 handler."""
242
+ return jsonify({"error": "Not found"}), 404
243
+
244
+ @self.app.errorhandler(500)
245
+ def server_error(error):
246
+ """500 handler."""
247
+ return jsonify({"error": "Internal server error"}), 500
248
+
249
+ def run(self, host: str = "0.0.0.0", port: int = 8011, debug: bool = False):
250
+ """Run the API server."""
251
+ print(f"[*] StreamMCP API v0.3 running on {host}:{port}")
252
+ print(f"[*] Database: {self.db_path}")
253
+ print(f"[*] Endpoints: /health, /api/v1/search, /api/v1/route, /api/v1/stats")
254
+ self.app.run(host=host, port=port, debug=debug, threaded=True)
255
+
256
+ def close(self):
257
+ """Close resources."""
258
+ self.persistence.close()
259
+
260
+
261
+ def create_app(db_path: str = ":memory:") -> Flask:
262
+ """Create Flask app."""
263
+ api = StreamMCPAPI(db_path)
264
+ return api.app
265
+
266
+
267
+ def run_server(db_path: Optional[str] = None, port: int = 8011):
268
+ """Run the REST API server."""
269
+ if db_path is None:
270
+ db_path = ":memory:"
271
+
272
+ api = StreamMCPAPI(db_path)
273
+ api.run(port=port, debug=False)
pystreammcp/cli.py ADDED
@@ -0,0 +1,385 @@
1
+ """CLI for PyStreamMCP v0.3 - Metadata Indexing & Routing."""
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import click
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+ from rich.panel import Panel
12
+ from rich.syntax import Syntax
13
+
14
+ from pystreammcp.core import (
15
+ MetadataIndex,
16
+ CapabilityClassification,
17
+ RelationType,
18
+ Metadata,
19
+ )
20
+
21
+ console = Console()
22
+
23
+
24
+ class Context:
25
+ """CLI context for state management."""
26
+
27
+ def __init__(self):
28
+ self.index = MetadataIndex()
29
+
30
+
31
+ pass_context = click.make_pass_decorator(Context)
32
+
33
+
34
+ @click.group()
35
+ @click.pass_context
36
+ def cli(ctx):
37
+ """StreamMCP v0.2 - Intelligence Layer for MCP Systems.
38
+
39
+ Discover, index, and intelligently route queries across MCP servers.
40
+ """
41
+ ctx.obj = Context()
42
+
43
+
44
+ @cli.command()
45
+ @click.option("--file", "-f", type=click.Path(exists=True), help="Load tools from JSON file")
46
+ @click.option("--format", "-fmt", type=click.Choice(["json", "csv"]), default="json")
47
+ @pass_context
48
+ def discover(ctx: Context, file: Optional[str], format: str):
49
+ """Discover MCP servers and tools.
50
+
51
+ Scans connected MCP servers and catalogs available tools.
52
+ """
53
+ console.print("[bold cyan]🔍 MCP Server Discovery[/bold cyan]")
54
+
55
+ if file:
56
+ try:
57
+ with open(file) as f:
58
+ data = json.load(f)
59
+ tools = data.get("tools", [])
60
+ console.print(f"[green]✓[/green] Loaded {len(tools)} tools from {file}")
61
+
62
+ # Index tools
63
+ result = ctx.index.bulk_index_tools(tools)
64
+ console.print(f"\n[bold]Indexing Results:[/bold]")
65
+ console.print(f" Total: {result['total']}")
66
+ console.print(f" Success: [green]{result['success']}[/green]")
67
+ console.print(f" Errors: [red]{result['errors']}[/red]")
68
+ console.print(f" Duration: {result['duration_ms']}ms")
69
+
70
+ # Show stats
71
+ stats = ctx.index.stats()
72
+ console.print(f"\n[bold]Index Statistics:[/bold]")
73
+ console.print(f" Metadata entries: {stats['total_metadata']}")
74
+ console.print(f" Capabilities: {stats['total_capabilities']}")
75
+ console.print(f" Schemas: {stats['total_schemas']}")
76
+ console.print(f" Relationships: {stats['total_relationships']}")
77
+ except FileNotFoundError:
78
+ console.print(f"[red]✗ File not found: {file}[/red]", file=sys.stderr)
79
+ sys.exit(1)
80
+ else:
81
+ console.print(
82
+ "[yellow]⚠ Mock discovery (provide --file for real tools)[/yellow]"
83
+ )
84
+ console.print("\n[bold]Available Servers:[/bold]")
85
+ console.print(" • web_server (4 tools)")
86
+ console.print(" • file_server (3 tools)")
87
+ console.print(" • database_server (5 tools)")
88
+ console.print(" • github_server (8 tools)")
89
+
90
+
91
+ @cli.command()
92
+ @click.argument("query")
93
+ @click.option("--classification", "-c", help="Filter by capability classification")
94
+ @click.option("--server", "-s", help="Filter by server")
95
+ @click.option("--limit", "-l", type=int, default=10, help="Max results")
96
+ @pass_context
97
+ def search(ctx: Context, query: str, classification: Optional[str], server: Optional[str], limit: int):
98
+ """Search for tools by name or description.
99
+
100
+ Find relevant tools using intelligent metadata search.
101
+ """
102
+ console.print(f"[bold cyan]🔎 Searching: '{query}'[/bold cyan]\n")
103
+
104
+ results = ctx.index.search_by_name(query)
105
+
106
+ if classification:
107
+ try:
108
+ cap_class = CapabilityClassification[classification.title()]
109
+ results = [
110
+ m
111
+ for m in results
112
+ if any(
113
+ c.classification == cap_class
114
+ for c in ctx.index.capability_discovery.get_by_metadata(m.id)
115
+ )
116
+ ]
117
+ except KeyError:
118
+ console.print(f"[red]Unknown classification: {classification}[/red]", file=sys.stderr)
119
+ sys.exit(1)
120
+
121
+ if server:
122
+ results = [m for m in results if m.server_id == server]
123
+
124
+ results = results[:limit]
125
+
126
+ if not results:
127
+ console.print("[yellow]No tools found matching your search.[/yellow]")
128
+ return
129
+
130
+ # Show results table
131
+ table = Table(title=f"Search Results ({len(results)})", show_header=True)
132
+ table.add_column("Tool", style="cyan")
133
+ table.add_column("Server", style="magenta")
134
+ table.add_column("Description")
135
+ table.add_column("Type", style="yellow")
136
+
137
+ for metadata in results:
138
+ capabilities = ctx.index.capability_discovery.get_by_metadata(metadata.id)
139
+ cap_type = (
140
+ capabilities[0].classification.value if capabilities else "unknown"
141
+ )
142
+ table.add_row(
143
+ metadata.name,
144
+ metadata.server_id,
145
+ metadata.description[:50] + "..."
146
+ if len(metadata.description) > 50
147
+ else metadata.description,
148
+ cap_type,
149
+ )
150
+
151
+ console.print(table)
152
+
153
+
154
+ @cli.command()
155
+ @click.argument("tool_id")
156
+ @pass_context
157
+ def inspect(ctx: Context, tool_id: str):
158
+ """Inspect a specific tool in detail.
159
+
160
+ Show full metadata, schema, capabilities, and relationships.
161
+ """
162
+ # Search for the tool
163
+ all_metadata = ctx.index.get_all_metadata()
164
+ metadata = None
165
+ for m in all_metadata:
166
+ if m.tool_id == tool_id or m.name == tool_id or m.id == tool_id:
167
+ metadata = m
168
+ break
169
+
170
+ if not metadata:
171
+ console.print(f"[red]✗ Tool not found: {tool_id}[/red]", file=sys.stderr)
172
+ sys.exit(1)
173
+
174
+ console.print(f"\n[bold cyan]📋 Tool: {metadata.name}[/bold cyan]\n")
175
+
176
+ # Basic info
177
+ info = Panel(
178
+ f"[cyan]ID:[/cyan] {metadata.id}\n"
179
+ f"[cyan]Server:[/cyan] {metadata.server_id}\n"
180
+ f"[cyan]Tool ID:[/cyan] {metadata.tool_id}\n"
181
+ f"[cyan]Description:[/cyan] {metadata.description}",
182
+ title="Basic Info",
183
+ )
184
+ console.print(info)
185
+
186
+ # Capabilities
187
+ capabilities = ctx.index.capability_discovery.get_by_metadata(metadata.id)
188
+ if capabilities:
189
+ cap_text = "\n".join(
190
+ [
191
+ f"[yellow]{c.classification.value}[/yellow] "
192
+ f"(cost: ${c.cost_estimate:.2f}, latency: {c.latency_estimate_ms}ms)"
193
+ for c in capabilities
194
+ ]
195
+ )
196
+ cap_panel = Panel(cap_text, title="Capabilities")
197
+ console.print(cap_panel)
198
+
199
+ # Schema
200
+ schemas = ctx.index.get_tool_schema(metadata.id)
201
+ if schemas:
202
+ schema_panel = Panel(
203
+ json.dumps([asdict(s) for s in schemas], indent=2, default=str),
204
+ title="Schemas",
205
+ )
206
+ console.print(schema_panel)
207
+
208
+
209
+ @cli.command()
210
+ @click.option("--server", "-s", help="Filter by server")
211
+ @pass_context
212
+ def stats(ctx: Context, server: Optional[str]):
213
+ """Show metadata index statistics.
214
+
215
+ Display overall indexing statistics and distribution.
216
+ """
217
+ console.print("[bold cyan]📊 Index Statistics[/bold cyan]\n")
218
+
219
+ stats_data = ctx.index.stats()
220
+
221
+ # Overall stats
222
+ table = Table(show_header=False)
223
+ table.add_row("Total Metadata", f"[cyan]{stats_data['total_metadata']}[/cyan]")
224
+ table.add_row("Total Capabilities", f"[green]{stats_data['total_capabilities']}[/green]")
225
+ table.add_row("Total Schemas", f"[yellow]{stats_data['total_schemas']}[/yellow]")
226
+ table.add_row(
227
+ "Total Relationships", f"[magenta]{stats_data['total_relationships']}[/magenta]"
228
+ )
229
+ console.print(table)
230
+
231
+ # Capability distribution
232
+ console.print("\n[bold]Capability Distribution:[/bold]")
233
+ for classification in CapabilityClassification:
234
+ if classification == CapabilityClassification.Unknown:
235
+ continue
236
+ count = len(ctx.index.get_capabilities_by_classification(classification))
237
+ bar = "█" * (count // 2)
238
+ console.print(f" {classification.value:12} {bar} ({count})")
239
+
240
+ # Server distribution
241
+ if not server:
242
+ console.print("\n[bold]Servers:[/bold]")
243
+ servers: dict = {}
244
+ for metadata in ctx.index.get_all_metadata():
245
+ servers[metadata.server_id] = servers.get(metadata.server_id, 0) + 1
246
+ for srv, count in sorted(servers.items()):
247
+ console.print(f" {srv}: {count} tools")
248
+
249
+
250
+ @cli.command()
251
+ @click.argument("name")
252
+ @click.argument("description")
253
+ @click.option("--server", "-s", required=True, help="Server ID")
254
+ @click.option("--tool-id", "-t", required=True, help="Tool ID")
255
+ @click.option("--input-schema", "-i", type=click.Path(exists=True), help="Input schema JSON file")
256
+ @click.option("--output-schema", "-o", type=click.Path(exists=True), help="Output schema JSON file")
257
+ @pass_context
258
+ def index(ctx: Context, name: str, description: str, server: str, tool_id: str, input_schema: Optional[str], output_schema: Optional[str]):
259
+ """Index a new tool.
260
+
261
+ Add a tool to the metadata index.
262
+ """
263
+ console.print(f"[bold cyan]📝 Indexing Tool: {name}[/bold cyan]\n")
264
+
265
+ # Load schemas
266
+ input_json = {}
267
+ output_json = {}
268
+
269
+ if input_schema:
270
+ try:
271
+ with open(input_schema) as f:
272
+ input_json = json.load(f)
273
+ except FileNotFoundError:
274
+ console.print(f"[red]Input schema file not found: {input_schema}[/red]", file=sys.stderr)
275
+ sys.exit(1)
276
+
277
+ if output_schema:
278
+ try:
279
+ with open(output_schema) as f:
280
+ output_json = json.load(f)
281
+ except FileNotFoundError:
282
+ console.print(f"[red]Output schema file not found: {output_schema}[/red]", file=sys.stderr)
283
+ sys.exit(1)
284
+
285
+ # Index
286
+ try:
287
+ ctx.index.index_tool(
288
+ name=name,
289
+ description=description,
290
+ server_id=server,
291
+ tool_id=tool_id,
292
+ input_schema=input_json,
293
+ output_schema=output_json,
294
+ )
295
+ console.print("[green]✓[/green] Tool indexed successfully")
296
+ stats = ctx.index.stats()
297
+ console.print(f"[dim]Index now contains {stats['total_metadata']} tools[/dim]")
298
+ except Exception as e:
299
+ console.print(f"[red]✗ Indexing failed: {e}[/red]", file=sys.stderr)
300
+ sys.exit(1)
301
+
302
+
303
+ @cli.command()
304
+ @click.option("--capabilities", "-c", is_flag=True, help="Show capability details")
305
+ @click.option("--schemas", "-s", is_flag=True, help="Show schema details")
306
+ @pass_context
307
+ def list_tools(ctx: Context, capabilities: bool, schemas: bool):
308
+ """List all indexed tools.
309
+
310
+ Display all tools currently in the index.
311
+ """
312
+ all_metadata = ctx.index.get_all_metadata()
313
+
314
+ if not all_metadata:
315
+ console.print("[yellow]No tools indexed.[/yellow]")
316
+ return
317
+
318
+ console.print(f"[bold cyan]📚 Indexed Tools ({len(all_metadata)})[/bold cyan]\n")
319
+
320
+ table = Table(show_header=True)
321
+ table.add_column("Name", style="cyan")
322
+ table.add_column("Server", style="magenta")
323
+ table.add_column("Type", style="yellow")
324
+ table.add_column("Capabilities", style="green")
325
+
326
+ for metadata in all_metadata:
327
+ caps = ctx.index.capability_discovery.get_by_metadata(metadata.id)
328
+ cap_types = ", ".join([c.classification.value for c in caps])
329
+ table.add_row(
330
+ metadata.name,
331
+ metadata.server_id,
332
+ metadata.tool_id,
333
+ cap_types or "unknown",
334
+ )
335
+
336
+ console.print(table)
337
+
338
+ if capabilities:
339
+ console.print("\n[bold]Capability Details:[/bold]")
340
+ for metadata in all_metadata:
341
+ caps = ctx.index.capability_discovery.get_by_metadata(metadata.id)
342
+ if caps:
343
+ console.print(f"\n {metadata.name}:")
344
+ for cap in caps:
345
+ console.print(
346
+ f" • {cap.classification.value} "
347
+ f"(inputs: {len(cap.inputs)}, outputs: {len(cap.outputs)})"
348
+ )
349
+
350
+ if schemas:
351
+ console.print("\n[bold]Schemas:[/bold]")
352
+ for metadata in all_metadata:
353
+ schemas = ctx.index.get_tool_schema(metadata.id)
354
+ if schemas:
355
+ console.print(f"\n {metadata.name}:")
356
+ for schema in schemas:
357
+ console.print(f" • {schema.base_type.value} (props: {len(schema.properties)})")
358
+
359
+
360
+ @cli.command()
361
+ @pass_context
362
+ def version(ctx: Context):
363
+ """Show version information."""
364
+ from streammcp import __version__
365
+
366
+ console.print(f"[bold]StreamMCP[/bold] v{__version__}")
367
+ console.print("[dim]Intelligence Layer for MCP Systems[/dim]")
368
+ console.print(f"\nPhase: v0.2 - Metadata-First Architecture")
369
+ console.print("Status: [yellow]In Development[/yellow]")
370
+
371
+
372
+ def main():
373
+ """Entry point."""
374
+ try:
375
+ cli()
376
+ except KeyboardInterrupt:
377
+ console.print("\n[yellow]Cancelled[/yellow]")
378
+ sys.exit(0)
379
+ except Exception as e:
380
+ console.print(f"[red]Error: {e}[/red]", file=sys.stderr)
381
+ sys.exit(1)
382
+
383
+
384
+ if __name__ == "__main__":
385
+ main()