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

1"""CLI entry points for GolfMCP.""" 

2 

3import atexit 

4import os 

5from pathlib import Path 

6 

7import typer 

8from rich.console import Console 

9 

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) 

20 

21# Create console for rich output 

22console = Console() 

23 

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) 

30 

31# Register telemetry shutdown on exit 

32atexit.register(shutdown) 

33 

34 

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() 

40 

41 

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" 

69 

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" 

74 

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]") 

79 

80 

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. 

87 

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) 

95 

96 # Import here to avoid circular imports 

97 from golf.commands.init import initialize_project 

98 

99 # Use the current directory if no output directory is specified 

100 if output_dir is None: 

101 output_dir = Path.cwd() / project_name 

102 

103 # Execute the initialization command (it handles its own tracking) 

104 initialize_project(project_name=project_name, output_dir=output_dir) 

105 

106 

107# Create a build group with subcommands 

108build_app = typer.Typer(help="Build a standalone FastMCP application") 

109app.add_typer(build_app, name="build") 

110 

111 

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. 

117 

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() 

123 

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) 

139 

140 # Load settings from the found project 

141 settings = load_settings(project_root) 

142 

143 # Set default output directory if not specified 

144 output_dir = project_root / "dist" if output_dir is None else Path(output_dir) 

145 

146 try: 

147 # Build the project with environment variables copied 

148 from golf.commands.build import build_project 

149 

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 

162 

163 

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. 

169 

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. 

173 

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() 

178 

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) 

194 

195 # Load settings from the found project 

196 settings = load_settings(project_root) 

197 

198 # Set default output directory if not specified 

199 output_dir = project_root / "dist" if output_dir is None else Path(output_dir) 

200 

201 try: 

202 # Build the project without copying environment variables 

203 from golf.commands.build import build_project 

204 

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 

217 

218 

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. 

227 

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() 

233 

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) 

248 

249 # Load settings from the found project 

250 settings = load_settings(project_root) 

251 

252 # Set default dist directory if not specified 

253 dist_dir = project_root / "dist" if dist_dir is None else Path(dist_dir) 

254 

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 

262 

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) 

286 

287 try: 

288 # Import and run the server 

289 from golf.commands.run import run_server 

290 

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 ) 

298 

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") 

314 

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 ) 

336 

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 

349 

350 

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) 

366 

367 

368if __name__ == "__main__": 

369 # Show welcome banner when run directly 

370 create_welcome_banner(__version__, console) 

371 

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 ) 

377 

378 # Run the CLI app 

379 app()