Coverage for /Users/antonigmitruk/golf/src/golf/commands/init.py: 0%

1"""Project initialization command implementation."""

2

3 import shutil

4 from pathlib import Path

5

6 from rich.console import Console

7 from rich.progress import Progress, SpinnerColumn, TextColumn

8 from rich.prompt import Confirm

9

10 from golf.cli.branding import (

11 create_success_message,

12 create_info_panel,

13 STATUS_ICONS,

14 GOLF_ORANGE,

15)

16

17 from golf.core.telemetry import (

18 track_command,

19 track_event,

20 set_telemetry_enabled,

21 load_telemetry_preference,

22)

23

24 console = Console()

25

26

27 def initialize_project(

28 project_name: str,

29 output_dir: Path,

30) -> None:

31 """Initialize a new GolfMCP project.

32

33 Args:

34 project_name: Name of the project

35 output_dir: Directory where the project will be created

36 """

37 try:

38 # Use the basic template by default

39 template = "basic"

40

41 # Check if directory exists

42 if output_dir.exists():

43 if not output_dir.is_dir():

44 console.print(f"[bold red]Error:[/bold red] '{output_dir}' exists but is not a directory.")

45 track_command(

46 "init",

47 success=False,

48 error_type="NotADirectory",

49 error_message="Target exists but is not a directory",

50 )

51 return

52

53 # Check if directory is empty

54 if any(output_dir.iterdir()) and not Confirm.ask(

55 f"Directory '{output_dir}' is not empty. Continue anyway?",

56 default=False,

57 ):

58 console.print("Initialization cancelled.")

59 track_event("cli_init_cancelled", {"success": False})

60 return

61 else:

62 # Create the directory

63 output_dir.mkdir(parents=True)

64

65 # Find template directory within the installed package

66 import golf

67

68 package_init_file = Path(golf.__file__)

69 # The 'examples' directory is now inside the 'golf' package directory

70 # e.g. golf/examples/basic, so go up one from __init__.py to get to 'golf'

71 template_dir = package_init_file.parent / "examples" / template

72

73 if not template_dir.exists():

74 console.print(f"[bold red]Error:[/bold red] Could not find template '{template}'")

75 track_command(

76 "init",

77 success=False,

78 error_type="TemplateNotFound",

79 error_message=f"Template directory not found: {template}",

80 )

81 return

82

83 # Copy template files

84 with Progress(

85 SpinnerColumn(),

86 TextColumn(

87 f"[bold {GOLF_ORANGE}]{STATUS_ICONS['building']} Creating project structure...[/bold {GOLF_ORANGE}]"

88 ),

89 transient=True,

90 ) as progress:

91 progress.add_task("copying", total=None)

92

93 # Copy directory structure

94 _copy_template(template_dir, output_dir, project_name)

95

96 # Ask for telemetry consent

97 _prompt_for_telemetry_consent()

98

99 # Show success message

100 console.print()

101 create_success_message("Project initialized successfully!", console)

102

103 # Show next steps

104 next_steps = f"cd {output_dir.name}\ngolf build dev\ngolf run"

105 create_info_panel("Next Steps", next_steps, console)

106

107 # Track successful initialization

108 track_event("cli_init_success", {"success": True, "template": template})

109 except Exception as e:

110 # Capture error details for telemetry

111 error_type = type(e).__name__

112 error_message = str(e)

113

114 console.print(f"[bold red]Error during initialization:[/bold red] {error_message}")

115 track_command("init", success=False, error_type=error_type, error_message=error_message)

116

117 # Re-raise to maintain existing behavior

118 raise

119

120

121 def _copy_template(source_dir: Path, target_dir: Path, project_name: str) -> None:

122 """Copy template files to the target directory, with variable substitution.

123

124 Args:

125 source_dir: Source template directory

126 target_dir: Target project directory

127 project_name: Name of the project (for substitutions)

128 """

129 # Create standard directory structure

130 (target_dir / "tools").mkdir(exist_ok=True)

131 (target_dir / "resources").mkdir(exist_ok=True)

132 (target_dir / "prompts").mkdir(exist_ok=True)

133

134 # Copy all files from the template

135 for source_path in source_dir.glob("**/*"):

136 # Skip if directory (we'll create directories as needed)

137 if source_path.is_dir():

138 continue

139

140 # Compute relative path

141 rel_path = source_path.relative_to(source_dir)

142 target_path = target_dir / rel_path

143

144 # Create parent directories if needed

145 target_path.parent.mkdir(parents=True, exist_ok=True)

146

147 # Copy and substitute content for text files

148 if _is_text_file(source_path):

149 with open(source_path, encoding="utf-8") as f:

150 content = f.read()

151

152 # Replace template variables

153 content = content.replace("{{project_name}}", project_name)

154 content = content.replace("{{project_name_lowercase}}", project_name.lower())

155

156 with open(target_path, "w", encoding="utf-8") as f:

157 f.write(content)

158 else:

159 # Binary file, just copy

160 shutil.copy2(source_path, target_path)

161

162 # Create a .gitignore if it doesn't exist

163 gitignore_file = target_dir / ".gitignore"

164 if not gitignore_file.exists():

165 with open(gitignore_file, "w", encoding="utf-8") as f:

166 f.write("# Python\n")

167 f.write("__pycache__/\n")

168 f.write("*.py[cod]\n")

169 f.write("*$py.class\n")

170 f.write("*.so\n")

171 f.write(".Python\n")

172 f.write("env/\n")

173 f.write("build/\n")

174 f.write("develop-eggs/\n")

175 f.write("dist/\n")

176 f.write("downloads/\n")

177 f.write("eggs/\n")

178 f.write(".eggs/\n")

179 f.write("lib/\n")

180 f.write("lib64/\n")

181 f.write("parts/\n")

182 f.write("sdist/\n")

183 f.write("var/\n")

184 f.write("*.egg-info/\n")

185 f.write(".installed.cfg\n")

186 f.write("*.egg\n\n")

187 f.write("# Environment\n")

188 f.write(".env\n")

189 f.write(".venv\n")

190 f.write("env/\n")

191 f.write("venv/\n")

192 f.write("ENV/\n")

193 f.write("env.bak/\n")

194 f.write("venv.bak/\n\n")

195 f.write("# GolfMCP\n")

196 f.write(".golf/\n")

197 f.write("dist/\n")

198

199

200 def _prompt_for_telemetry_consent() -> None:

201 """Prompt user for telemetry consent and save their preference."""

202 import os

203

204 # Skip prompt in test mode, when telemetry is explicitly disabled, or if

205 # preference already exists

206 if os.environ.get("GOLF_TEST_MODE", "").lower() in ("1", "true", "yes", "on"):

207 return

208

209 # Skip if telemetry is explicitly disabled in environment

210 if os.environ.get("GOLF_TELEMETRY", "").lower() in ("0", "false", "no", "off"):

211 return

212

213 # Check if user already has a saved preference

214 existing_preference = load_telemetry_preference()

215 if existing_preference is not None:

216 return # User already made a choice

217

218 console.print()

219 console.rule("[bold blue]Anonymous usage analytics[/bold blue]", style="blue")

220 console.print()

221 console.print("Golf can collect [bold]anonymous usage analytics[/bold] to help improve the tool.")

222 console.print()

223 console.print("[dim]What we collect:[/dim]")

224 console.print(" • Command usage (init, build, run)")

225 console.print(" • Error types (to fix bugs)")

226 console.print(" • Golf version and Python version")

227 console.print(" • Operating system type")

228 console.print()

229 console.print("[dim]What we DON'T collect:[/dim]")

230 console.print(" • Your code or project content")

231 console.print(" • File paths or project names")

232 console.print(" • Personal information")

233 console.print(" • IP addresses")

234 console.print()

235 console.print("You can change this anytime by setting GOLF_TELEMETRY=0 in your environment.")

236 console.print()

237

238 enable_telemetry = Confirm.ask("[bold]Enable anonymous usage analytics?[/bold]", default=False)

239

240 set_telemetry_enabled(enable_telemetry, persist=True)

241

242 if enable_telemetry:

243 console.print("[green]✓[/green] Anonymous analytics enabled")

244 else:

245 console.print("[yellow]○[/yellow] Anonymous analytics disabled")

246 console.print()

247

248

249 def _is_text_file(path: Path) -> bool:

250 """Check if a file is a text file that needs variable substitution.

251

252 Args:

253 path: Path to check

254

255 Returns:

256 True if the file is a text file

257 """

258 # List of known text file extensions

259 text_extensions = {

260 ".py",

261 ".md",

262 ".txt",

263 ".html",

264 ".css",

265 ".js",

266 ".json",

267 ".yml",

268 ".yaml",

269 ".toml",

270 ".ini",

271 ".cfg",

272 ".env",

273 ".example",

274 }

275

276 # Check if the file has a text extension

277 if path.suffix in text_extensions:

278 return True

279

280 # Check specific filenames without extensions

281 if path.name in {".gitignore", "README", "LICENSE"}:

282 return True

283

284 # Try to detect if it's a text file by reading a bit of it

285 try:

286 with open(path, encoding="utf-8") as f:

287 f.read(1024)

288 return True

289 except UnicodeDecodeError:

290 return False