Coverage for /Users/antonigmitruk/golf/src/golf/cli/main.py: 0%
124 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-08-16 18:46 +0200
« prev ^ index » next coverage.py v7.6.12, created at 2025-08-16 18:46 +0200
1"""CLI entry points for GolfMCP."""
3import atexit
4import os
5from pathlib import Path
7import typer
8from rich.console import Console
10from golf import __version__
11from golf.cli.branding import create_welcome_banner, create_command_header
12from golf.core.config import find_project_root, load_settings
13from golf.core.telemetry import (
14 is_telemetry_enabled,
15 set_telemetry_enabled,
16 shutdown,
17 track_event,
18 track_detailed_error,
19)
21# Create console for rich output
22console = Console()
24# Create the typer app instance
25app = typer.Typer(
26 name="golf",
27 help="GolfMCP: A Pythonic framework for building MCP servers with zero boilerplate",
28 add_completion=False,
29)
31# Register telemetry shutdown on exit
32atexit.register(shutdown)
35def _version_callback(value: bool) -> None:
36 """Print version and exit if --version flag is used."""
37 if value:
38 create_welcome_banner(__version__, console)
39 raise typer.Exit()
42@app.callback()
43def callback(
44 version: bool = typer.Option(
45 None,
46 "--version",
47 "-V",
48 help="Show the version and exit.",
49 callback=_version_callback,
50 is_eager=True,
51 ),
52 verbose: bool = typer.Option(False, "--verbose", "-v", help="Increase verbosity of output."),
53 no_telemetry: bool = typer.Option(
54 False,
55 "--no-telemetry",
56 help="Disable telemetry collection (persists for future commands).",
57 ),
58 test: bool = typer.Option(
59 False,
60 "--test",
61 hidden=True,
62 help="Run in test mode (disables telemetry for this execution only).",
63 ),
64) -> None:
65 """GolfMCP: A Pythonic framework for building MCP servers with zero boilerplate."""
66 # Set verbosity in environment for other components to access
67 if verbose:
68 os.environ["GOLF_VERBOSE"] = "1"
70 # Set test mode if flag is used (temporary, just for this execution)
71 if test:
72 set_telemetry_enabled(False, persist=False)
73 os.environ["GOLF_TEST_MODE"] = "1"
75 # Set telemetry preference if flag is used (permanent)
76 if no_telemetry:
77 set_telemetry_enabled(False, persist=True)
78 console.print("[dim]Telemetry has been disabled. You can re-enable it with: golf telemetry enable[/dim]")
81@app.command()
82def init(
83 project_name: str = typer.Argument(..., help="Name of the project to create"),
84 output_dir: Path | None = typer.Option(None, "--output-dir", "-o", help="Directory to create the project in"),
85) -> None:
86 """Initialize a new GolfMCP project.
88 Creates a new directory with the project scaffold, including
89 examples for tools, resources, and prompts.
90 """
91 # Show the Golf logo for project initialization
92 create_welcome_banner(__version__, console)
93 console.print()
94 create_command_header("Initialize Project", f"Creating {project_name}", console)
96 # Import here to avoid circular imports
97 from golf.commands.init import initialize_project
99 # Use the current directory if no output directory is specified
100 if output_dir is None:
101 output_dir = Path.cwd() / project_name
103 # Execute the initialization command (it handles its own tracking)
104 initialize_project(project_name=project_name, output_dir=output_dir)
107# Create a build group with subcommands
108build_app = typer.Typer(help="Build a standalone FastMCP application")
109app.add_typer(build_app, name="build")
112@build_app.command("dev")
113def build_dev(
114 output_dir: str | None = typer.Option(None, "--output-dir", "-o", help="Directory to output the built project"),
115) -> None:
116 """Build a development version with app environment variables copied.
118 Golf credentials (GOLF_*) are always loaded from .env for build operations.
119 All environment variables are copied to the built project for development.
120 """
121 # Find project root directory
122 project_root, config_path = find_project_root()
124 if not project_root:
125 console.print(
126 "[bold red]Error: No GolfMCP project found in the current directory or any parent directory.[/bold red]"
127 )
128 console.print("Run 'golf init <project_name>' to create a new project.")
129 track_event(
130 "cli_build_failed",
131 {
132 "success": False,
133 "environment": "dev",
134 "error_type": "NoProjectFound",
135 "error_message": "No GolfMCP project found",
136 },
137 )
138 raise typer.Exit(code=1)
140 # Load settings from the found project
141 settings = load_settings(project_root)
143 # Set default output directory if not specified
144 output_dir = project_root / "dist" if output_dir is None else Path(output_dir)
146 try:
147 # Build the project with environment variables copied
148 from golf.commands.build import build_project
150 build_project(project_root, settings, output_dir, build_env="dev", copy_env=True)
151 # Track successful build with environment
152 track_event("cli_build_success", {"success": True, "environment": "dev"})
153 except Exception as e:
154 track_detailed_error(
155 "cli_build_failed",
156 e,
157 context="Development build with environment variables",
158 operation="build_dev",
159 additional_props={"environment": "dev", "copy_env": True},
160 )
161 raise
164@build_app.command("prod")
165def build_prod(
166 output_dir: str | None = typer.Option(None, "--output-dir", "-o", help="Directory to output the built project"),
167) -> None:
168 """Build a production version for deployment.
170 Golf credentials (GOLF_*) are always loaded from .env for build operations
171 (platform registration, resource updates). App environment variables are
172 NOT copied for security - provide them in your deployment environment.
174 Your production deployment must include GOLF_* vars for runtime telemetry.
175 """
176 # Find project root directory
177 project_root, config_path = find_project_root()
179 if not project_root:
180 console.print(
181 "[bold red]Error: No GolfMCP project found in the current directory or any parent directory.[/bold red]"
182 )
183 console.print("Run 'golf init <project_name>' to create a new project.")
184 track_event(
185 "cli_build_failed",
186 {
187 "success": False,
188 "environment": "prod",
189 "error_type": "NoProjectFound",
190 "error_message": "No GolfMCP project found",
191 },
192 )
193 raise typer.Exit(code=1)
195 # Load settings from the found project
196 settings = load_settings(project_root)
198 # Set default output directory if not specified
199 output_dir = project_root / "dist" if output_dir is None else Path(output_dir)
201 try:
202 # Build the project without copying environment variables
203 from golf.commands.build import build_project
205 build_project(project_root, settings, output_dir, build_env="prod", copy_env=False)
206 # Track successful build with environment
207 track_event("cli_build_success", {"success": True, "environment": "prod"})
208 except Exception as e:
209 track_detailed_error(
210 "cli_build_failed",
211 e,
212 context="Production build without environment variables",
213 operation="build_prod",
214 additional_props={"environment": "prod", "copy_env": False},
215 )
216 raise
219@app.command()
220def run(
221 dist_dir: str | None = typer.Option(None, "--dist-dir", "-d", help="Directory containing the built server"),
222 host: str | None = typer.Option(None, "--host", "-h", help="Host to bind to (overrides settings)"),
223 port: int | None = typer.Option(None, "--port", "-p", help="Port to bind to (overrides settings)"),
224 build_first: bool = typer.Option(True, "--build/--no-build", help="Build the project before running"),
225) -> None:
226 """Run the built FastMCP server.
228 This command runs the built server from the dist directory.
229 By default, it will build the project first if needed.
230 """
231 # Find project root directory
232 project_root, config_path = find_project_root()
234 if not project_root:
235 console.print(
236 "[bold red]Error: No GolfMCP project found in the current directory or any parent directory.[/bold red]"
237 )
238 console.print("Run 'golf init <project_name>' to create a new project.")
239 track_event(
240 "cli_run_failed",
241 {
242 "success": False,
243 "error_type": "NoProjectFound",
244 "error_message": "No GolfMCP project found",
245 },
246 )
247 raise typer.Exit(code=1)
249 # Load settings from the found project
250 settings = load_settings(project_root)
252 # Set default dist directory if not specified
253 dist_dir = project_root / "dist" if dist_dir is None else Path(dist_dir)
255 # Check if dist directory exists
256 if not dist_dir.exists():
257 if build_first:
258 console.print(f"[yellow]Dist directory {dist_dir} not found. Building first...[/yellow]")
259 try:
260 # Build the project
261 from golf.commands.build import build_project
263 build_project(project_root, settings, dist_dir)
264 except Exception as e:
265 console.print(f"[bold red]Error building project:[/bold red] {str(e)}")
266 track_detailed_error(
267 "cli_run_failed",
268 e,
269 context="Auto-build before running server",
270 operation="auto_build_before_run",
271 additional_props={"auto_build": True},
272 )
273 raise
274 else:
275 console.print(f"[bold red]Error: Dist directory {dist_dir} not found.[/bold red]")
276 console.print("Run 'golf build' first or use --build to build automatically.")
277 track_event(
278 "cli_run_failed",
279 {
280 "success": False,
281 "error_type": "DistNotFound",
282 "error_message": "Dist directory not found",
283 },
284 )
285 raise typer.Exit(code=1)
287 try:
288 # Import and run the server
289 from golf.commands.run import run_server
291 return_code = run_server(
292 project_path=project_root,
293 settings=settings,
294 dist_dir=dist_dir,
295 host=host,
296 port=port,
297 )
299 # Track based on return code with better categorization
300 if return_code == 0:
301 track_event("cli_run_success", {"success": True})
302 elif return_code in [130, 143, 137, 2]:
303 # Intentional shutdowns (not errors):
304 # 130: Ctrl+C (SIGINT)
305 # 143: SIGTERM (graceful shutdown, e.g., Kubernetes, Docker)
306 # 137: SIGKILL (forced shutdown)
307 # 2: General interrupt/graceful shutdown
308 shutdown_type = {
309 130: "UserInterrupt",
310 143: "GracefulShutdown",
311 137: "ForcedShutdown",
312 2: "Interrupt",
313 }.get(return_code, "GracefulShutdown")
315 track_event(
316 "cli_run_shutdown",
317 {
318 "success": True, # Not an error
319 "shutdown_type": shutdown_type,
320 "exit_code": return_code,
321 },
322 )
323 else:
324 # Actual errors (unexpected exit codes)
325 track_event(
326 "cli_run_failed",
327 {
328 "success": False,
329 "error_type": "UnexpectedExit",
330 "error_message": (f"Server process exited unexpectedly with code {return_code}"),
331 "exit_code": return_code,
332 "operation": "server_process_execution",
333 "context": "Server process terminated with unexpected exit code",
334 },
335 )
337 # Exit with the same code as the server
338 if return_code != 0:
339 raise typer.Exit(code=return_code)
340 except Exception as e:
341 track_detailed_error(
342 "cli_run_failed",
343 e,
344 context="Server execution or startup failure",
345 operation="run_server_execution",
346 additional_props={"has_dist_dir": dist_dir.exists() if dist_dir else False},
347 )
348 raise
351# Add telemetry command group
352@app.command()
353def telemetry(
354 action: str = typer.Argument(..., help="Action to perform: 'enable' or 'disable'"),
355) -> None:
356 """Manage telemetry settings."""
357 if action.lower() == "enable":
358 set_telemetry_enabled(True, persist=True)
359 console.print("[green]✓[/green] Telemetry enabled. Thank you for helping improve Golf!")
360 elif action.lower() == "disable":
361 set_telemetry_enabled(False, persist=True)
362 console.print("[yellow]Telemetry disabled.[/yellow] You can re-enable it anytime with: golf telemetry enable")
363 else:
364 console.print(f"[red]Unknown action '{action}'. Use 'enable' or 'disable'.[/red]")
365 raise typer.Exit(code=1)
368if __name__ == "__main__":
369 # Show welcome banner when run directly
370 create_welcome_banner(__version__, console)
372 # Add telemetry notice if enabled
373 if is_telemetry_enabled():
374 console.print(
375 "[dim]📊 Anonymous usage data is collected to improve Golf. Disable with: golf telemetry disable[/dim]\n"
376 )
378 # Run the CLI app
379 app()