create-forge 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
create_forge/cli.py ADDED
@@ -0,0 +1,824 @@
1
+ """Command line interface for create-forge."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from dataclasses import dataclass
10
+ from importlib.metadata import PackageNotFoundError, version
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Annotated
13
+
14
+ import typer
15
+ from rich.console import Console
16
+ from rich.panel import Panel
17
+ from rich.table import Table
18
+
19
+ from create_forge.compat import (
20
+ ENGINE_DISTRIBUTION,
21
+ SUPPORTED_ENGINE_RANGE,
22
+ SUPPORTED_PROJECTSPEC_PROTOCOLS,
23
+ )
24
+ from create_forge.config import (
25
+ UserConfig,
26
+ config_path,
27
+ env_overrides,
28
+ load_config,
29
+ write_example,
30
+ )
31
+ from create_forge.prompts import (
32
+ ArchetypeChoice,
33
+ PromptAbortedError,
34
+ ask_all,
35
+ choose_archetype,
36
+ choose_template,
37
+ slugify,
38
+ )
39
+ from create_forge.registry import load_registry
40
+ from create_forge.runner import ScaffoldError, ScaffoldRequest, scaffold, update
41
+ from create_forge.staging import (
42
+ DestinationConflictError,
43
+ StagingError,
44
+ ensure_available,
45
+ )
46
+
47
+ if TYPE_CHECKING:
48
+ from collections.abc import Sequence
49
+
50
+ from create_forge.models import Registry, Template
51
+
52
+ app = typer.Typer(
53
+ name="create-forge",
54
+ help="Scaffold modern Python projects from maintained templates.",
55
+ no_args_is_help=True,
56
+ add_completion=False,
57
+ )
58
+ console = Console()
59
+ err = Console(stderr=True)
60
+
61
+ config_app = typer.Typer(
62
+ name="config",
63
+ help="Inspect or initialise your create-forge configuration.",
64
+ no_args_is_help=True,
65
+ )
66
+ app.add_typer(config_app, name="config")
67
+
68
+
69
+ def _dist_version(name: str) -> str:
70
+ """Installed version of a distribution, or "unknown" if it can't be found.
71
+
72
+ Editable installs of `create-forge` itself hit the fallback in normal
73
+ development; a distribution genuinely not being installed (e.g. `copier`
74
+ in some hypothetical stripped environment) hits it too.
75
+ """
76
+ try:
77
+ return version(name)
78
+ except PackageNotFoundError: # pragma: no cover - editable installs
79
+ return "unknown"
80
+
81
+
82
+ def _optional_dist_version(name: str) -> str | None:
83
+ """Installed version of an optional distribution, or `None` if absent.
84
+
85
+ Distinct from `_dist_version`: `create-forge` always depends on `name`
86
+ there, so "unknown" signals a broken environment. Here `name` is the
87
+ `engine` extra's `forge-template` -- not installed is the normal,
88
+ expected v0.2.x default, and docs/engine-resolution.md's diagnostics
89
+ contract documents `integration.engine_package` as `null` for it, not
90
+ the string "unknown".
91
+ """
92
+ try:
93
+ return version(name)
94
+ except PackageNotFoundError:
95
+ return None
96
+
97
+
98
+ def _version() -> str:
99
+ return _dist_version("create-forge")
100
+
101
+
102
+ def _parse_data(pairs: list[str]) -> dict[str, object]:
103
+ """Turn `--data key=value` into answers, coercing obvious booleans."""
104
+ parsed: dict[str, object] = {}
105
+ for pair in pairs:
106
+ key, sep, value = pair.partition("=")
107
+ if not sep:
108
+ msg = f"--data expects key=value, got {pair!r}"
109
+ raise typer.BadParameter(msg)
110
+ lowered = value.lower()
111
+ if lowered in {"true", "false"}:
112
+ parsed[key] = lowered == "true"
113
+ else:
114
+ parsed[key] = value
115
+ return parsed
116
+
117
+
118
+ def _version_callback(show: bool) -> None:
119
+ """Print the version and exit, if `--version` was passed."""
120
+ if show:
121
+ console.print(_version())
122
+ raise typer.Exit
123
+
124
+
125
+ @app.callback()
126
+ def main(
127
+ _version_flag: Annotated[
128
+ bool,
129
+ typer.Option(
130
+ "--version",
131
+ callback=_version_callback,
132
+ is_eager=True,
133
+ help="Show the version and exit.",
134
+ ),
135
+ ] = False,
136
+ ) -> None:
137
+ """create-forge."""
138
+
139
+
140
+ def _load_config_or_exit() -> UserConfig:
141
+ """Load user config, exiting with a plain-language error if it is malformed."""
142
+ try:
143
+ return load_config()
144
+ except ValueError as exc:
145
+ err.print(f"[red]{exc}[/red]")
146
+ raise typer.Exit(1) from exc
147
+
148
+
149
+ def _select_template(
150
+ registry: Registry, config: UserConfig, template_id: str | None, *, yes: bool
151
+ ) -> Template:
152
+ """Resolve which template to scaffold from --template, config, or a prompt.
153
+
154
+ Also emits the deprecation warning, since it depends on the same
155
+ resolution the caller needs the return value for.
156
+ """
157
+ try:
158
+ preferred_id = (
159
+ registry.get(config.default_template).id
160
+ if config.default_template
161
+ else registry.default_template
162
+ )
163
+ except KeyError as exc:
164
+ err.print(f"[red]{config_path()} sets default_template: {exc.args[0]}[/red]")
165
+ raise typer.Exit(1) from exc
166
+
167
+ try:
168
+ if template_id:
169
+ template = registry.get(template_id)
170
+ elif yes:
171
+ template = registry.get(preferred_id)
172
+ else:
173
+ template = choose_template(registry.selectable, preferred_id)
174
+ except KeyError as exc:
175
+ err.print(f"[red]{exc.args[0]}[/red]")
176
+ raise typer.Exit(1) from exc
177
+ except PromptAbortedError:
178
+ raise typer.Exit(130) from None
179
+
180
+ if template.status == "deprecated":
181
+ err.print(
182
+ f"[yellow]{template.id} is deprecated. "
183
+ f"Use {template.deprecated_in_favour_of} instead.[/yellow]"
184
+ )
185
+
186
+ return template
187
+
188
+
189
+ def _collect_answers(
190
+ template: Template,
191
+ preset: dict[str, object],
192
+ cfg_answers: dict[str, object],
193
+ *,
194
+ yes: bool,
195
+ ) -> dict[str, object]:
196
+ """Gather answers from --yes/--data, or by prompting for anything missing."""
197
+ if yes:
198
+ if "project_name" not in preset:
199
+ err.print("[red]--yes requires a project name.[/red]")
200
+ raise typer.Exit(1)
201
+ return {**cfg_answers, **preset}
202
+
203
+ try:
204
+ return {
205
+ **cfg_answers,
206
+ **ask_all(template, preset=preset, defaults=cfg_answers),
207
+ }
208
+ except PromptAbortedError:
209
+ err.print("\n[dim]Cancelled.[/dim]")
210
+ raise typer.Exit(130) from None
211
+
212
+
213
+ def _confirm_third_party(template_url: str | None, *, yes: bool) -> None:
214
+ """Warn and, unless --yes, ask for confirmation before running foreign code."""
215
+ if not template_url:
216
+ return
217
+ err.print(
218
+ Panel(
219
+ f"Scaffolding from [bold]{template_url}[/bold]\n"
220
+ "Template code will be executed. Only continue if you trust it.",
221
+ title="[yellow]Third-party template[/yellow]",
222
+ border_style="yellow",
223
+ )
224
+ )
225
+ if not yes and not typer.confirm("Continue?", default=False):
226
+ raise typer.Exit(130)
227
+
228
+
229
+ def _run_scaffold(request: ScaffoldRequest, slug: str) -> None:
230
+ """Scaffold, translating a ScaffoldError into a clean exit."""
231
+ try:
232
+ with console.status(f"Scaffolding {slug}…"):
233
+ scaffold(request)
234
+ except ScaffoldError as exc:
235
+ err.print(f"[red]{exc}[/red]")
236
+ raise typer.Exit(1) from exc
237
+
238
+
239
+ def _select_archetype(
240
+ archetypes: Sequence[ArchetypeChoice], archetype: str | None, *, yes: bool
241
+ ) -> str:
242
+ """Resolve which archetype to build: explicit, --yes, then a prompt.
243
+
244
+ CF-08.02. Mirrors `_select_template`'s resolution shape for the Copier
245
+ path, but
246
+ with no config- or registry-supplied default: the engine declares no
247
+ default archetype, and `templates.toml`'s `default_template` is a
248
+ Copier-path concept the engine path deliberately does not inherit.
249
+ """
250
+ available = {a.id for a in archetypes}
251
+
252
+ if archetype is not None:
253
+ if archetype not in available:
254
+ err.print(
255
+ f"[red]Unknown archetype {archetype!r}. Available: "
256
+ f"{', '.join(sorted(available))}[/red]"
257
+ )
258
+ raise typer.Exit(1)
259
+ return archetype
260
+
261
+ if yes:
262
+ err.print(
263
+ "[red]--engine-preview with --yes requires --archetype. "
264
+ f"Available: {', '.join(sorted(available))}[/red]"
265
+ )
266
+ raise typer.Exit(1)
267
+
268
+ try:
269
+ return choose_archetype(archetypes).id
270
+ except PromptAbortedError:
271
+ err.print("\n[dim]Cancelled.[/dim]")
272
+ raise typer.Exit(130) from None
273
+
274
+
275
+ def _run_engine_preview(
276
+ answers: dict[str, object],
277
+ archetype: str | None,
278
+ dst: Path,
279
+ *,
280
+ dry_run: bool,
281
+ yes: bool,
282
+ ) -> None:
283
+ """The --engine-preview path: discover, select, build, validate, render.
284
+
285
+ Stages and finalises into `dst` exactly like the Copier path, just
286
+ through the engine (ADR 0015). `forge-template` is the optional `engine`
287
+ extra (ADR 0018) -- not installed by a plain `pip install create-forge`
288
+ -- so the import is lazy and guarded: every other command, and `new`
289
+ without this flag, must keep working with the dependency absent.
290
+ """
291
+ err.print("[dim]--engine-preview is a hidden preview path (ADR 0014).[/dim]")
292
+ try:
293
+ ensure_available(dst)
294
+ except DestinationConflictError as exc:
295
+ err.print(f"[red]{exc}[/red]")
296
+ raise typer.Exit(1) from exc
297
+
298
+ try:
299
+ # Lazy by necessity, not style: forge-template is the optional
300
+ # `engine` extra (ADR 0018), not installed by default, so this
301
+ # import must not run unless this branch is actually reached.
302
+ # `engine` is imported directly here (rather than accessed as
303
+ # `pipeline.engine`) so mypy's strict implicit-reexport check has a
304
+ # real, direct import to type against.
305
+ from create_forge import engine, pipeline # noqa: PLC0415
306
+ except ImportError:
307
+ err.print(
308
+ "[red]The engine extra isn't installed.[/red] Run "
309
+ "`pip install 'create-forge[engine]'` (or `uv sync --all-extras` "
310
+ "in a create-forge checkout) to use it."
311
+ )
312
+ raise typer.Exit(1) from None
313
+
314
+ try:
315
+ archetypes = pipeline.discover_archetypes()
316
+ except engine.EngineCompatibilityError as exc:
317
+ err.print(f"[red]{exc}[/red]")
318
+ raise typer.Exit(3) from exc
319
+ except engine.ForgeEngineError as exc:
320
+ err.print(f"[red]{engine.explain(exc)}[/red]")
321
+ raise typer.Exit(1) from exc
322
+
323
+ resolved_archetype = _select_archetype(archetypes, archetype, yes=yes)
324
+
325
+ try:
326
+ request = pipeline.build_generation_request(
327
+ answers, archetype=resolved_archetype
328
+ )
329
+ except engine.EngineCompatibilityError as exc:
330
+ err.print(f"[red]{exc}[/red]")
331
+ raise typer.Exit(3) from exc
332
+ except engine.ForgeEngineError as exc:
333
+ err.print(f"[red]{engine.explain(exc)}[/red]")
334
+ raise typer.Exit(1) from exc
335
+
336
+ if dry_run:
337
+ for file in request.rendered.files:
338
+ console.print(f"[dim]would write[/dim] {file.target}")
339
+ console.print("[dim]Dry run — nothing written.[/dim]")
340
+ return
341
+
342
+ try:
343
+ pipeline.finalise_generation_request(request, dst)
344
+ except StagingError as exc:
345
+ err.print(f"[red]{exc}[/red]")
346
+ raise typer.Exit(1) from exc
347
+
348
+ _report_created(answers["project_name"], dst, updatable=False)
349
+
350
+
351
+ def _report_created(project_name: object, dst: Path, *, updatable: bool = True) -> None:
352
+ """Print the success panel once a project has actually been written."""
353
+ update_line = (
354
+ "[dim]Pull later template changes with: uvx create-forge update[/dim]"
355
+ if updatable
356
+ else "[dim]Built via --engine-preview -- create-forge update does not "
357
+ "apply.[/dim]"
358
+ )
359
+ console.print(
360
+ Panel(
361
+ f"[bold]{project_name}[/bold] created at [dim]{dst}[/dim]\n\n"
362
+ f" cd {dst.name}\n"
363
+ " uv run poe check\n\n"
364
+ f"{update_line}",
365
+ border_style="green",
366
+ )
367
+ )
368
+
369
+
370
+ @app.command("new")
371
+ def new( # noqa: PLR0913, PLR0917 - a CLI entry point's options are its public surface; one parameter per --flag is unavoidable
372
+ name: Annotated[
373
+ str | None,
374
+ typer.Argument(help="Project name. Prompted for when omitted."),
375
+ ] = None,
376
+ template_id: Annotated[
377
+ str | None,
378
+ typer.Option("--template", "-t", help="Template to use."),
379
+ ] = None,
380
+ path: Annotated[
381
+ Path | None,
382
+ typer.Option("--path", "-p", help="Where to create the project."),
383
+ ] = None,
384
+ data: Annotated[
385
+ list[str] | None,
386
+ typer.Option("--data", "-d", help="Preset an answer: key=value. Repeatable."),
387
+ ] = None,
388
+ yes: Annotated[
389
+ bool,
390
+ typer.Option("--yes", "-y", help="Skip prompts; use template defaults."),
391
+ ] = False,
392
+ template_url: Annotated[
393
+ str | None,
394
+ typer.Option(
395
+ "--template-url",
396
+ help="Clone from a different template. Runs its code — only use "
397
+ "sources you trust.",
398
+ ),
399
+ ] = None,
400
+ ref: Annotated[
401
+ str | None,
402
+ typer.Option("--ref", help="Template version. Defaults to the latest tag."),
403
+ ] = None,
404
+ dry_run: Annotated[
405
+ bool,
406
+ typer.Option("--dry-run", help="Show what would be written, write nothing."),
407
+ ] = False,
408
+ engine_preview: Annotated[
409
+ bool,
410
+ typer.Option(
411
+ "--engine-preview",
412
+ hidden=True,
413
+ help="Development-only: build via the public forge-template engine "
414
+ "instead of Copier. Combine with --archetype to pick a "
415
+ "component; omit it to be prompted.",
416
+ ),
417
+ ] = False,
418
+ archetype: Annotated[
419
+ str | None,
420
+ typer.Option(
421
+ "--archetype",
422
+ hidden=True,
423
+ help="Development-only: the engine archetype to build. Requires "
424
+ "--engine-preview.",
425
+ ),
426
+ ] = None,
427
+ ) -> None:
428
+ """Create a new project."""
429
+ if archetype is not None and not engine_preview:
430
+ err.print("[red]--archetype requires --engine-preview.[/red]")
431
+ raise typer.Exit(1)
432
+
433
+ registry = load_registry()
434
+ preset = _parse_data(data or [])
435
+ if name:
436
+ preset.setdefault("project_name", name)
437
+
438
+ config = _load_config_or_exit()
439
+ cfg_answers = config.as_answers()
440
+
441
+ template = _select_template(registry, config, template_id, yes=yes)
442
+ answers = _collect_answers(template, preset, cfg_answers, yes=yes)
443
+
444
+ slug = slugify(str(answers["project_name"]))
445
+ dst = (path or Path.cwd() / slug).resolve()
446
+
447
+ if engine_preview:
448
+ _run_engine_preview(answers, archetype, dst, dry_run=dry_run, yes=yes)
449
+ return
450
+
451
+ src = template_url or str(template.url)
452
+
453
+ _confirm_third_party(template_url, yes=yes)
454
+ _run_scaffold(
455
+ ScaffoldRequest(src=src, dst=dst, data=answers, vcs_ref=ref, dry_run=dry_run),
456
+ slug,
457
+ )
458
+
459
+ if dry_run:
460
+ console.print("[dim]Dry run — nothing written.[/dim]")
461
+ return
462
+
463
+ _report_created(answers["project_name"], dst)
464
+
465
+
466
+ @app.command("list")
467
+ def list_templates() -> None:
468
+ """Show the available templates."""
469
+ registry = load_registry()
470
+ table = Table(box=None, pad_edge=False)
471
+ table.add_column("ID", style="bold")
472
+ table.add_column("Name")
473
+ table.add_column("Description", style="dim")
474
+ table.add_column("Status")
475
+
476
+ for template in registry.templates:
477
+ marker = "" if template.status == "stable" else f"[yellow]{template.status}[/]"
478
+ default = (
479
+ " [dim](default)[/dim]" if template.id == registry.default_template else ""
480
+ )
481
+ table.add_row(
482
+ template.id + default, template.name, template.description, marker
483
+ )
484
+
485
+ console.print(table)
486
+
487
+
488
+ @app.command("update")
489
+ def update_project(
490
+ project: Annotated[Path, typer.Argument(help="Project directory.")] = Path(),
491
+ ref: Annotated[
492
+ str | None, typer.Option("--ref", help="Target version. Defaults to latest.")
493
+ ] = None,
494
+ ) -> None:
495
+ """Pull template changes into an existing project."""
496
+ try:
497
+ with console.status("Updating…"):
498
+ update(project.resolve(), vcs_ref=ref)
499
+ except ScaffoldError as exc:
500
+ err.print(f"[red]{exc}[/red]")
501
+ raise typer.Exit(1) from exc
502
+
503
+ console.print(
504
+ "[green]Updated.[/green] Review the diff before committing — "
505
+ "conflicts are marked inline."
506
+ )
507
+
508
+
509
+ def _markers(target: Console) -> tuple[str, str]:
510
+ """Return (pass, fail) markers the console's encoding can actually render.
511
+
512
+ A Windows console on the cp1252 codepage -- the default outside Windows
513
+ Terminal -- cannot encode the check-mark glyphs, and Rich lets the
514
+ resulting UnicodeEncodeError propagate rather than degrading. `doctor`
515
+ needs markers it knows will survive before it ever tries to print them.
516
+ """
517
+ try:
518
+ "✓✗".encode(target.encoding)
519
+ except (UnicodeEncodeError, LookupError):
520
+ return "OK", "FAIL"
521
+ return "✓", "✗"
522
+
523
+
524
+ @dataclass(frozen=True, slots=True)
525
+ class Check:
526
+ """One row of `doctor` output.
527
+
528
+ `informational` rows report a fact rather than a pass/fail condition (the
529
+ installed Copier version, say) and never affect `doctor`'s exit status —
530
+ only `passed=False` on a non-informational row does.
531
+ """
532
+
533
+ name: str
534
+ passed: bool
535
+ detail: str
536
+ informational: bool = False
537
+
538
+
539
+ @dataclass(frozen=True, slots=True)
540
+ class Integration:
541
+ """The active create-forge/forge-template integration line and its
542
+ versions -- see docs/engine-resolution.md for what each field means and
543
+ when it is populated. Since ADR 0018, `engine_range` and
544
+ `projectspec_supported` are always populated: a released range and
545
+ supported protocol are now declared regardless of whether the `engine`
546
+ extra is installed. `engine_package` is `None` until it is;
547
+ `projectspec_detected` stays `None` until a command actually imports and
548
+ negotiates with the engine (`doctor` never does -- see its own
549
+ docstring).
550
+ """ # noqa: D205
551
+
552
+ line: str
553
+ copier: str
554
+ engine_package: str | None
555
+ engine_range: str | None
556
+ projectspec_supported: str | None
557
+ projectspec_detected: str | None
558
+ template_source: str | None
559
+ template_ref: str | None
560
+
561
+
562
+ @dataclass(frozen=True, slots=True)
563
+ class ConfigSummary:
564
+ """Where config was read from and which keys it set."""
565
+
566
+ path: str
567
+ keys: list[str]
568
+
569
+
570
+ @dataclass(frozen=True, slots=True)
571
+ class Diagnostics:
572
+ """Everything `doctor` reports, gathered once so the table and `--json`
573
+ output can never disagree.
574
+ """ # noqa: D205
575
+
576
+ create_forge: str
577
+ python: str
578
+ platform: str
579
+ integration: Integration
580
+ config: ConfigSummary
581
+ checks: list[Check]
582
+
583
+ @property
584
+ def ok(self) -> bool:
585
+ """Whether every non-informational check passed."""
586
+ return all(check.passed for check in self.checks if not check.informational)
587
+
588
+
589
+ def _gather_diagnostics() -> Diagnostics:
590
+ """Run every doctor check and collect every reportable fact.
591
+
592
+ `doctor` stays offline: it reports the registry's bundled template source
593
+ but never resolves a ref, since that would mean a network call for what
594
+ is meant to be a fast local health check. For the same reason, engine
595
+ presence is read via `importlib.metadata` only -- never imported -- so
596
+ `projectspec_detected` (which needs a real `get_engine_info()` call) stays
597
+ `None` here regardless of whether the `engine` extra is installed.
598
+ """
599
+ checks: list[Check] = []
600
+
601
+ def check(passed: bool, name: str, detail: str) -> None:
602
+ checks.append(Check(name, passed, detail))
603
+
604
+ def info(name: str, detail: str) -> None:
605
+ checks.append(Check(name, True, detail, informational=True))
606
+
607
+ py = sys.version_info
608
+ python_version = f"{py.major}.{py.minor}.{py.micro}"
609
+ check(py >= (3, 11), "Python 3.11+", python_version)
610
+
611
+ for tool, why in (
612
+ ("git", "required to clone templates"),
613
+ ("uv", "required by generated projects"),
614
+ ):
615
+ found = shutil.which(tool)
616
+ check(bool(found), tool, found or f"not on PATH — {why}")
617
+
618
+ if shutil.which("git"):
619
+ name = _git_config("user.name")
620
+ email = _git_config("user.email")
621
+ check(
622
+ bool(name and email),
623
+ "git identity",
624
+ f"{name} <{email}>"
625
+ if name and email
626
+ else "unset — scaffolding cannot commit",
627
+ )
628
+
629
+ registry: Registry | None = None
630
+ try:
631
+ registry = load_registry()
632
+ check(True, "registry", f"{len(registry.templates)} template(s)")
633
+ except RuntimeError as exc:
634
+ check(False, "registry", str(exc).splitlines()[0])
635
+
636
+ template_source: str | None = None
637
+ if registry is not None:
638
+ default = registry.get(registry.default_template)
639
+ template_source = str(default.url)
640
+ source_detail = (
641
+ f"{template_source} (default: {registry.default_template}, "
642
+ "ref: latest PEP 440 tag — resolved at scaffold time)"
643
+ )
644
+ else:
645
+ source_detail = "unavailable — registry did not load"
646
+
647
+ config_keys: list[str] = []
648
+ try:
649
+ config = load_config()
650
+ config_keys = sorted(config.model_dump(exclude_none=True))
651
+ keys_detail = ", ".join(config_keys) or "no values set"
652
+ check(True, "config", f"{config_path()} — {keys_detail}")
653
+ except ValueError as exc:
654
+ check(False, "config", str(exc).splitlines()[0])
655
+
656
+ engine_range = f"{ENGINE_DISTRIBUTION}{SUPPORTED_ENGINE_RANGE}"
657
+ engine_package = _optional_dist_version(ENGINE_DISTRIBUTION)
658
+ projectspec_supported = ",".join(str(p) for p in SUPPORTED_PROJECTSPEC_PROTOCOLS)
659
+
660
+ info("create-forge", _dist_version("create-forge"))
661
+ info("copier", _dist_version("copier"))
662
+ info("template source", source_detail)
663
+ info(
664
+ "engine",
665
+ f"{ENGINE_DISTRIBUTION} {engine_package} installed (supports {engine_range})"
666
+ if engine_package is not None
667
+ else f"not installed (supports {engine_range}) — "
668
+ "install with pip install 'create-forge[engine]'",
669
+ )
670
+ info(
671
+ "ProjectSpec protocol",
672
+ f"supported: {projectspec_supported} (detected: requires the "
673
+ "engine extra and a real negotiation, not performed by doctor)",
674
+ )
675
+
676
+ return Diagnostics(
677
+ create_forge=_dist_version("create-forge"),
678
+ python=python_version,
679
+ platform=sys.platform,
680
+ integration=Integration(
681
+ line="v0.2.x-copier",
682
+ copier=_dist_version("copier"),
683
+ engine_package=engine_package,
684
+ engine_range=engine_range,
685
+ projectspec_supported=projectspec_supported,
686
+ projectspec_detected=None,
687
+ template_source=template_source,
688
+ template_ref=None,
689
+ ),
690
+ config=ConfigSummary(path=str(config_path()), keys=config_keys),
691
+ checks=checks,
692
+ )
693
+
694
+
695
+ def _render_diagnostics_table(diagnostics: Diagnostics, target: Console) -> None:
696
+ """Render `doctor`'s checks as the human-facing Rich table."""
697
+ passed_marker, failed_marker = _markers(target)
698
+
699
+ table = Table(box=None, pad_edge=False)
700
+ table.add_column("")
701
+ table.add_column("Check")
702
+ table.add_column("Detail", style="dim")
703
+
704
+ for entry in diagnostics.checks:
705
+ if entry.informational:
706
+ table.add_row("[dim]-[/]", entry.name, entry.detail)
707
+ continue
708
+ marker = passed_marker if entry.passed else failed_marker
709
+ style = "green" if entry.passed else "red"
710
+ table.add_row(f"[{style}]{marker}[/]", entry.name, entry.detail)
711
+
712
+ target.print(table)
713
+
714
+
715
+ def _diagnostics_payload(diagnostics: Diagnostics) -> dict[str, object]:
716
+ """The stable JSON shape `doctor --json` emits.
717
+
718
+ Documented field-by-field in docs/engine-resolution.md's diagnostics
719
+ contract -- new fields may be added, but existing ones keep their meaning.
720
+ """
721
+ integration = diagnostics.integration
722
+ return {
723
+ "create_forge": diagnostics.create_forge,
724
+ "python": diagnostics.python,
725
+ "platform": diagnostics.platform,
726
+ "integration": {
727
+ "line": integration.line,
728
+ "copier": integration.copier,
729
+ "engine_package": integration.engine_package,
730
+ "engine_range": integration.engine_range,
731
+ "projectspec_protocol": {
732
+ "supported": integration.projectspec_supported,
733
+ "detected": integration.projectspec_detected,
734
+ },
735
+ "template_source": integration.template_source,
736
+ "template_ref": integration.template_ref,
737
+ },
738
+ "config": {"path": diagnostics.config.path, "keys": diagnostics.config.keys},
739
+ "checks": [
740
+ {"name": c.name, "ok": c.passed, "detail": c.detail}
741
+ for c in diagnostics.checks
742
+ if not c.informational
743
+ ],
744
+ "ok": diagnostics.ok,
745
+ }
746
+
747
+
748
+ @app.command("doctor")
749
+ def doctor(
750
+ as_json: Annotated[
751
+ bool,
752
+ typer.Option("--json", help="Print machine-readable diagnostics."),
753
+ ] = False,
754
+ ) -> None:
755
+ """Check that the environment can scaffold and update projects."""
756
+ diagnostics = _gather_diagnostics()
757
+
758
+ if as_json:
759
+ typer.echo(json.dumps(_diagnostics_payload(diagnostics), indent=2))
760
+ else:
761
+ _render_diagnostics_table(diagnostics, console)
762
+
763
+ if not diagnostics.ok:
764
+ raise typer.Exit(1)
765
+
766
+
767
+ @config_app.command("init")
768
+ def config_init() -> None:
769
+ """Write a commented starter config file. Never overwrites an existing one."""
770
+ target = config_path()
771
+ existed = target.exists()
772
+ write_example(target)
773
+ if existed:
774
+ console.print(f"[dim]{target} already exists — left untouched.[/dim]")
775
+ else:
776
+ console.print(f"[green]Wrote {target}.[/green] Edit it, then run `new` again.")
777
+
778
+
779
+ @config_app.command("show")
780
+ def config_show() -> None:
781
+ """Print resolved configuration and where each value came from."""
782
+ target = config_path()
783
+ if not target.is_file():
784
+ console.print(
785
+ f"[dim]{target} does not exist.[/dim] Run `create-forge config init`."
786
+ )
787
+
788
+ try:
789
+ config = load_config(target)
790
+ except ValueError as exc:
791
+ err.print(f"[red]{exc}[/red]")
792
+ raise typer.Exit(1) from exc
793
+
794
+ overridden = env_overrides()
795
+
796
+ table = Table(box=None, pad_edge=False)
797
+ table.add_column("Key")
798
+ table.add_column("Value")
799
+ table.add_column("Source", style="dim")
800
+
801
+ for field, value in config.model_dump().items():
802
+ if field in overridden:
803
+ source = "environment"
804
+ elif value is not None:
805
+ source = "config file"
806
+ else:
807
+ source = "unset"
808
+ table.add_row(field, str(value) if value is not None else "—", source)
809
+
810
+ console.print(table)
811
+
812
+
813
+ def _git_config(key: str) -> str | None:
814
+ try:
815
+ result = subprocess.run( # noqa: S603
816
+ ["git", "config", "--get", key], # noqa: S607
817
+ capture_output=True,
818
+ text=True,
819
+ check=False,
820
+ timeout=5,
821
+ )
822
+ except (OSError, subprocess.TimeoutExpired): # pragma: no cover
823
+ return None
824
+ return result.stdout.strip() or None