Coverage for lintro / cli_utils / commands / setup.py: 50%
163 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Interactive setup command for project onboarding.
3Detects project languages, recommends tools, installs missing ones, and
4generates a project-appropriate .lintro-config.yaml. Inspired by Trunk's
5zero-friction init and rustup's profile system.
7Usage:
8 lintro setup # Interactive onboarding
9 lintro setup --profile ci --yes # Non-interactive for CI
10 lintro setup --dry-run # Show plan without executing
11"""
13from __future__ import annotations
15import sys
16from pathlib import Path
18import click
19from rich.console import Console
21from lintro.tools.core.install_context import RuntimeContext
22from lintro.tools.core.tool_installer import ToolInstaller
23from lintro.tools.core.tool_registry import ToolRegistry
24from lintro.utils.project_detection import (
25 detect_package_managers,
26 detect_project_languages,
27)
30def _generate_config(
31 tools: list[str],
32 detected_langs: list[str],
33) -> str:
34 """Generate .lintro-config.yaml content for the given tools.
36 Args:
37 tools: List of tool names to enable.
38 detected_langs: Detected languages for enforce settings.
40 Returns:
41 YAML config string.
42 """
43 lines = [
44 "# Lintro configuration — generated by 'lintro setup'",
45 "# Docs: https://github.com/lgtm-hq/py-lintro/tree/main/docs",
46 "",
47 "enforce:",
48 ]
50 # Python-specific enforce settings
51 if "python" in detected_langs:
52 lines.append(" line_length: 88")
54 lines.extend(
55 [
56 "",
57 "execution:",
58 f" enabled_tools: [{', '.join(sorted(tools))}]",
59 " tool_order: priority",
60 " fail_fast: false",
61 " parallel: true",
62 "",
63 ],
64 )
66 if "mypy" in tools:
67 lines.extend(
68 [
69 "defaults:",
70 " mypy:",
71 " strict: true",
72 " ignore_missing_imports: true",
73 "",
74 ],
75 )
77 lines.append("tools:")
78 for tool_name in sorted(tools):
79 lines.append(f" {tool_name}:")
80 lines.append(" enabled: true")
82 lines.append("")
83 return "\n".join(lines)
86@click.command()
87@click.option(
88 "--profile",
89 type=str,
90 help="Named profile (e.g., minimal, recommended, complete, ci).",
91)
92@click.option(
93 "--yes",
94 "-y",
95 is_flag=True,
96 help="Non-interactive mode (accept defaults).",
97)
98@click.option(
99 "--dry-run",
100 is_flag=True,
101 help="Show what would happen without making changes.",
102)
103@click.option(
104 "--skip-install",
105 is_flag=True,
106 help="Generate config only, don't install tools.",
107)
108@click.option(
109 "--config",
110 "config_path",
111 type=str,
112 default=".lintro-config.yaml",
113 help="Config output path (default: .lintro-config.yaml).",
114)
115def setup_command(
116 profile: str | None,
117 *,
118 yes: bool,
119 dry_run: bool,
120 skip_install: bool,
121 config_path: str,
122) -> None:
123 """Set up lintro for your project.
125 Scans your project, detects languages and frameworks, recommends tools,
126 installs missing ones, and generates a .lintro-config.yaml.
128 Args:
129 profile: Named profile to use.
130 yes: Non-interactive mode.
131 dry_run: Show plan without executing.
132 skip_install: Skip tool installation.
133 config_path: Output path for config file.
135 Raises:
136 click.ClickException: When an invalid profile name is provided.
138 Examples:
139 lintro setup
140 lintro setup --profile minimal --yes
141 lintro setup --dry-run
142 lintro setup --skip-install
143 """
144 console = Console()
146 console.print()
147 console.print(" [bold]Lintro Setup[/bold]")
148 console.print(" [dim]Scanning project...[/dim]")
149 console.print()
151 # ── Detection ──
152 detected_langs = detect_project_languages()
153 pkg_managers = detect_package_managers()
155 console.print(" [bold]Detected:[/bold]")
156 if detected_langs:
157 console.print(
158 f" Languages: {', '.join(lang.title() for lang in detected_langs)}",
159 )
160 else:
161 console.print(" Languages: [dim]none detected[/dim]")
162 if pkg_managers:
163 mgr_display = ", ".join(
164 f"{name} ({manifest})" for name, manifest in pkg_managers.items()
165 )
166 console.print(f" Pkg Mgrs: {mgr_display}")
167 console.print()
169 # ── Load registry ──
170 registry = ToolRegistry.load()
171 context = RuntimeContext.detect()
173 # ── Resolve profile ──
174 if not profile:
175 # Show recommended tools (use the same resolver the install path uses)
176 recommended = registry.tools_for_profile("recommended", detected_langs)
177 if recommended:
178 console.print(
179 f" [bold]Recommended tools for this project "
180 f"({len(recommended)} tools):[/bold]",
181 )
182 console.print()
184 # Group by language for display
185 lang_tools: dict[str, list[str]] = {}
186 for tool in recommended:
187 for lang in tool.languages:
188 lang_tools.setdefault(lang, []).append(tool.name)
190 for lang in sorted(lang_tools):
191 tool_display = ", ".join(sorted(set(lang_tools[lang])))
192 console.print(f" {lang.title():<20}{tool_display}")
194 console.print()
196 if yes:
197 profile = "recommended"
198 else:
199 # Interactive profile selection
200 profiles = registry.profiles
201 console.print(" [bold]Select profile:[/bold]")
202 # Preferred display order, then any additional profiles
203 preferred_order = ["recommended", "minimal", "complete", "ci"]
204 profile_list = [p for p in preferred_order if p in profiles]
205 profile_list.extend(p for p in sorted(profiles) if p not in preferred_order)
206 default_idx = (
207 profile_list.index("recommended") + 1
208 if "recommended" in profile_list
209 else 1
210 )
211 for i, pname in enumerate(profile_list, 1):
212 desc = profiles[pname].description
213 default = " (default)" if i == default_idx else ""
214 console.print(f" [{i}] {pname:<16}{desc}{default}")
216 console.print()
217 choice = click.prompt(
218 " Choice",
219 type=click.IntRange(1, len(profile_list)),
220 default=default_idx,
221 show_default=False,
222 )
223 profile = profile_list[choice - 1]
225 # Resolve profile to tool list
226 if profile not in registry.profile_names:
227 raise click.ClickException(
228 f"Unknown profile {profile!r}. "
229 f"Available: {', '.join(registry.profile_names)}",
230 )
231 selected_tools = registry.tools_for_profile(profile, detected_langs)
232 tool_names: list[str] = [t.name for t in selected_tools]
234 console.print()
235 console.print(
236 f" [dim]Profile: {profile} ({len(selected_tools)} tools)[/dim]",
237 )
239 # ── Install check ──
240 if not skip_install:
241 installer = ToolInstaller(registry, context)
242 plan = installer.plan(
243 tools=tool_names,
244 detected_langs=detected_langs,
245 )
247 if plan.has_work:
248 console.print()
249 console.print(
250 f" [bold]Missing tools ({len(plan.to_install)}):[/bold]",
251 )
252 for tool, cmd in plan.to_install:
253 console.print(
254 f" {tool.name:<20}({tool.install_type}) [dim]{cmd}[/dim]",
255 )
257 if dry_run:
258 console.print()
259 console.print(
260 f" [dim]Dry run: would install "
261 f"{len(plan.to_install)} tool(s).[/dim]",
262 )
263 elif yes or click.confirm(
264 "\n Install missing tools?",
265 default=True,
266 ):
267 console.print()
268 results = installer.execute(plan)
269 succeeded = sum(1 for r in results if r.success)
270 failed = sum(1 for r in results if not r.success)
271 for r in results:
272 if r.success:
273 console.print(f" [green]OK[/green] {r.tool.name}")
274 else:
275 console.print(
276 f" [red]FAIL[/red] {r.tool.name}: {r.message}",
277 )
278 if failed > 0:
279 console.print(
280 f"\n [yellow]{succeeded} installed, "
281 f"{failed} failed[/yellow]",
282 )
283 # Report outdated/skipped before exiting
284 if plan.outdated:
285 console.print()
286 console.print(
287 f" [yellow]Outdated tools"
288 f" ({len(plan.outdated)}):[/yellow]",
289 )
290 for tool, current_ver in plan.outdated:
291 console.print(
292 f" {tool.name:<20}"
293 f"[yellow]{current_ver}[/yellow]"
294 f" → [green]{tool.version}[/green]",
295 )
296 if plan.skipped:
297 console.print()
298 console.print(
299 f" [yellow]Skipped tools"
300 f" ({len(plan.skipped)}):[/yellow]",
301 )
302 for tool, reason in plan.skipped:
303 console.print(
304 f" {tool.name:<20}[dim]{reason}[/dim]",
305 )
306 sys.exit(1)
307 else:
308 console.print(
309 "\n [yellow]Setup incomplete: tool installation "
310 "declined.[/yellow]",
311 )
312 if plan.outdated:
313 console.print(
314 f" [yellow]Outdated: {len(plan.outdated)} tool(s)"
315 f" (use --upgrade to update)[/yellow]",
316 )
317 if plan.skipped:
318 console.print(
319 f" [yellow]Skipped: {len(plan.skipped)} tool(s)"
320 f" require manual installation[/yellow]",
321 )
322 sys.exit(1)
324 # Always surface skipped/outdated regardless of install outcome
325 if plan.outdated:
326 console.print()
327 console.print(
328 f" [yellow]Outdated tools ({len(plan.outdated)}):[/yellow]",
329 )
330 for tool, current_ver in plan.outdated:
331 console.print(
332 f" {tool.name:<20}[yellow]{current_ver}[/yellow]"
333 f" → [green]{tool.version}[/green]",
334 )
335 if plan.skipped:
336 console.print()
337 console.print(
338 f" [yellow]Skipped tools ({len(plan.skipped)}):[/yellow]",
339 )
340 for tool, reason in plan.skipped:
341 console.print(f" {tool.name:<20}[dim]{reason}[/dim]")
342 if plan.outdated:
343 console.print()
344 console.print(
345 " [yellow]Run 'lintro install --upgrade' to update.[/yellow]",
346 )
347 if plan.skipped:
348 console.print()
349 console.print(
350 " [yellow]Some tools require manual installation "
351 "(see details above).[/yellow]",
352 )
353 if (plan.outdated or plan.skipped) and not dry_run:
354 sys.exit(1)
355 elif not plan.has_work and not dry_run:
356 console.print(
357 f" [green]All {profile} tools already installed.[/green]",
358 )
360 # ── Generate config ──
361 config_file = Path(config_path)
362 if (
363 config_file.exists()
364 and not yes
365 and not dry_run
366 and not click.confirm(
367 f"\n {config_path} already exists. Overwrite?",
368 default=False,
369 )
370 ):
371 console.print(" [dim]Skipping config generation.[/dim]")
372 _print_next_steps(console)
373 return
375 config_content = _generate_config(tool_names, detected_langs)
377 if dry_run:
378 console.print()
379 console.print(f" [dim]Dry run: would write {config_path}[/dim]")
380 else:
381 try:
382 config_file.parent.mkdir(parents=True, exist_ok=True)
383 config_file.write_text(config_content, encoding="utf-8")
384 except OSError as exc:
385 raise click.ClickException(
386 f"Failed to write {config_path}: {exc}",
387 ) from exc
388 console.print()
389 console.print(
390 f" [green]Generated {config_path} "
391 f"with {len(tool_names)} tools enabled.[/green]",
392 )
394 _print_next_steps(console)
397def _print_next_steps(console: Console) -> None:
398 """Print post-setup guidance."""
399 console.print()
400 console.print(" [bold]Next steps:[/bold]")
401 console.print(" lintro check . [dim]Check all files[/dim]")
402 console.print(" lintro fmt . [dim]Auto-fix formatting[/dim]")
403 console.print(" lintro doctor [dim]Verify tool health[/dim]")
404 console.print()