Coverage for /Users/antonigmitruk/golf/src/golf/cli/main.py: 0%

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

2

3 import atexit

4 import os

5 from pathlib import Path

6

7 import typer

8 from rich.console import Console

9

10 from golf import __version__

11 from golf.cli.branding import create_welcome_banner, create_command_header

12 from golf.core.config import find_project_root, load_settings

13 from 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

22 console = Console()

23

24# Create the typer app instance

25 app = 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

32 atexit.register(shutdown)

33

34

35 def _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()

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

82 def 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

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

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

110

111

112@build_app.command("dev")

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

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

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

353 def 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

368 if __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()