simcon-toolkit 0.1.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.
Files changed (97) hide show
  1. simcon_toolkit/__init__.py +40 -0
  2. simcon_toolkit/__main__.py +7 -0
  3. simcon_toolkit/_kit/LICENSE +202 -0
  4. simcon_toolkit/_kit/NOTICE +37 -0
  5. simcon_toolkit/_kit/assets/parts/clip_frame.stl +0 -0
  6. simcon_toolkit/_kit/assets/parts/simple_plate.stl +0 -0
  7. simcon_toolkit/_kit/packages/.ruff.toml +10 -0
  8. simcon_toolkit/_kit/packages/cadmould_cloud/__init__.py +8 -0
  9. simcon_toolkit/_kit/packages/cadmould_cloud/auth.py +681 -0
  10. simcon_toolkit/_kit/packages/cadmould_cloud/client.py +235 -0
  11. simcon_toolkit/_kit/packages/cadmould_geometry/__init__.py +5 -0
  12. simcon_toolkit/_kit/packages/cadmould_geometry/mesh.py +210 -0
  13. simcon_toolkit/_kit/packages/cadmould_geometry/stl.py +168 -0
  14. simcon_toolkit/_kit/packages/cadmould_results/__init__.py +30 -0
  15. simcon_toolkit/_kit/packages/cadmould_results/loader.py +288 -0
  16. simcon_toolkit/_kit/packages/cadmould_scoring/__init__.py +7 -0
  17. simcon_toolkit/_kit/packages/cadmould_scoring/metrics.py +519 -0
  18. simcon_toolkit/_kit/pyproject.toml +232 -0
  19. simcon_toolkit/_kit/templates/_shared/AGENTS.base.md +101 -0
  20. simcon_toolkit/_kit/templates/gate-study/.gitignore +18 -0
  21. simcon_toolkit/_kit/templates/gate-study/AGENTS.md +46 -0
  22. simcon_toolkit/_kit/templates/gate-study/GATING_STUDY_PLAYBOOK.md +219 -0
  23. simcon_toolkit/_kit/templates/gate-study/INITIAL_PROMPT.md +26 -0
  24. simcon_toolkit/_kit/templates/gate-study/README.md +137 -0
  25. simcon_toolkit/_kit/templates/gate-study/main.py +344 -0
  26. simcon_toolkit/_kit/templates/gate-study/pipeline.py +281 -0
  27. simcon_toolkit/_kit/templates/process-window/.gitignore +20 -0
  28. simcon_toolkit/_kit/templates/process-window/AGENTS.md +49 -0
  29. simcon_toolkit/_kit/templates/process-window/METHOD.md +155 -0
  30. simcon_toolkit/_kit/templates/process-window/README.md +176 -0
  31. simcon_toolkit/_kit/templates/process-window/configs/simple-plate.yaml +116 -0
  32. simcon_toolkit/_kit/templates/process-window/doe_spec.schema.md +249 -0
  33. simcon_toolkit/_kit/templates/process-window/main.py +82 -0
  34. simcon_toolkit/_kit/templates/process-window/process_window/__init__.py +5 -0
  35. simcon_toolkit/_kit/templates/process-window/process_window/centre.py +298 -0
  36. simcon_toolkit/_kit/templates/process-window/process_window/design.py +144 -0
  37. simcon_toolkit/_kit/templates/process-window/process_window/economics.py +367 -0
  38. simcon_toolkit/_kit/templates/process-window/process_window/emit.py +591 -0
  39. simcon_toolkit/_kit/templates/process-window/process_window/guardrails.py +153 -0
  40. simcon_toolkit/_kit/templates/process-window/process_window/harness.py +360 -0
  41. simcon_toolkit/_kit/templates/process-window/process_window/identity.py +92 -0
  42. simcon_toolkit/_kit/templates/process-window/process_window/inspect_part.py +184 -0
  43. simcon_toolkit/_kit/templates/process-window/process_window/kpis.py +355 -0
  44. simcon_toolkit/_kit/templates/process-window/process_window/material_card.py +163 -0
  45. simcon_toolkit/_kit/templates/process-window/process_window/probe_proxy.py +169 -0
  46. simcon_toolkit/_kit/templates/process-window/process_window/run_confirm.py +403 -0
  47. simcon_toolkit/_kit/templates/process-window/process_window/run_epsilon_floor.py +198 -0
  48. simcon_toolkit/_kit/templates/process-window/process_window/run_feedback.py +322 -0
  49. simcon_toolkit/_kit/templates/process-window/process_window/run_refine.py +279 -0
  50. simcon_toolkit/_kit/templates/process-window/process_window/run_screening.py +370 -0
  51. simcon_toolkit/_kit/templates/process-window/process_window/run_sweep.py +166 -0
  52. simcon_toolkit/_kit/templates/process-window/process_window/setup_campaign.py +312 -0
  53. simcon_toolkit/_kit/templates/process-window/process_window/surrogate.py +201 -0
  54. simcon_toolkit/_kit/templates/process-window/process_window/test_centre.py +169 -0
  55. simcon_toolkit/_kit/templates/process-window/process_window/test_design.py +113 -0
  56. simcon_toolkit/_kit/templates/process-window/process_window/test_guardrails.py +157 -0
  57. simcon_toolkit/_kit/templates/process-window/process_window/test_surrogate.py +127 -0
  58. simcon_toolkit/_kit/templates/process-window/process_window/units.py +152 -0
  59. simcon_toolkit/_kit/templates/quoting/.gitignore +24 -0
  60. simcon_toolkit/_kit/templates/quoting/AGENTS.md +58 -0
  61. simcon_toolkit/_kit/templates/quoting/INTERVIEW.md +147 -0
  62. simcon_toolkit/_kit/templates/quoting/METHOD.md +256 -0
  63. simcon_toolkit/_kit/templates/quoting/PROMPT.md +46 -0
  64. simcon_toolkit/_kit/templates/quoting/QUOTING_PLAYBOOK.md +245 -0
  65. simcon_toolkit/_kit/templates/quoting/README.md +158 -0
  66. simcon_toolkit/_kit/templates/quoting/main.py +484 -0
  67. simcon_toolkit/_kit/templates/quoting/parts/.gitkeep +0 -0
  68. simcon_toolkit/_kit/templates/quoting/quoting/__init__.py +11 -0
  69. simcon_toolkit/_kit/templates/quoting/quoting/costing.py +725 -0
  70. simcon_toolkit/_kit/templates/quoting/quoting/geometry.py +398 -0
  71. simcon_toolkit/_kit/templates/quoting/quoting/shop.py +193 -0
  72. simcon_toolkit/_kit/templates/quoting/quoting/state.py +260 -0
  73. simcon_toolkit/_kit/templates/quoting/quoting/study.py +577 -0
  74. simcon_toolkit/_kit/templates/quoting/quoting/toolkit.py +50 -0
  75. simcon_toolkit/_kit/templates/quoting/shop/README.md +43 -0
  76. simcon_toolkit/_kit/templates/quoting/shop/commercial.md +86 -0
  77. simcon_toolkit/_kit/templates/quoting/shop/lessons.md +94 -0
  78. simcon_toolkit/_kit/templates/quoting/shop/machines.md +68 -0
  79. simcon_toolkit/_kit/templates/quoting/shop/materials.md +92 -0
  80. simcon_toolkit/_kit/templates/quoting/shop/shop-profile.md +87 -0
  81. simcon_toolkit/_kit/templates/quoting/shop/tooling.md +145 -0
  82. simcon_toolkit/_kit/templates/run-one-simulation/.gitignore +16 -0
  83. simcon_toolkit/_kit/templates/run-one-simulation/AGENTS.md +41 -0
  84. simcon_toolkit/_kit/templates/run-one-simulation/README.md +133 -0
  85. simcon_toolkit/_kit/templates/run-one-simulation/main.py +216 -0
  86. simcon_toolkit/_kit/templates.toml +83 -0
  87. simcon_toolkit/choices.py +11 -0
  88. simcon_toolkit/cli.py +381 -0
  89. simcon_toolkit/generate.py +590 -0
  90. simcon_toolkit/instructions.py +152 -0
  91. simcon_toolkit/manifest.py +86 -0
  92. simcon_toolkit/project.py +356 -0
  93. simcon_toolkit/wizard.py +160 -0
  94. simcon_toolkit-0.1.0.dist-info/METADATA +48 -0
  95. simcon_toolkit-0.1.0.dist-info/RECORD +97 -0
  96. simcon_toolkit-0.1.0.dist-info/WHEEL +4 -0
  97. simcon_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
simcon_toolkit/cli.py ADDED
@@ -0,0 +1,381 @@
1
+ """The command surface: one subcommand, one positional, five flags, one code path for all.
2
+
3
+ `init` is the only subcommand, and a bare `simcon-toolkit` prints the help and refuses
4
+ rather than guessing which one was meant. The subcommand is a namespace, not ceremony: it
5
+ leaves room beside `init` without ever making a project name ambiguous with a command.
6
+
7
+ There is no separate non-interactive mode. Every question has a flag, and only one answer is
8
+ REQUIRED: the template, because nothing else decides what the customer gets. The folder
9
+ name defaults to the template's name and the package manager to uv, both safe because a
10
+ clash stops the run before anything is written. So a command naming a template runs at once
11
+ on the defaults, whoever typed it. A command naming none opens the wizard for a person at a
12
+ terminal, which asks only what the flags left open, and is refused with the flag to pass
13
+ for anyone else. `--force` and `--list` answer no question.
14
+
15
+ Four conventions here are deliberate and each one looks like an oversight:
16
+
17
+ * `--package-manager` **carries no short form.** `-p` is `--python` in uv and `--project`
18
+ in pdm and hatch, and a Python scaffolder wants `--python` far more than it wants six
19
+ saved keystrokes on a flag typed once.
20
+ * `--yes` **is the documented name, with the Python-native spellings accepted quietly.**
21
+ cookiecutter teaches `--no-input` and copier teaches `--defaults`; neither should meet a
22
+ parse error, but the name a mould engineer and an assistant both reach for comes first.
23
+ * **Everything this module prints is ASCII.** (The wizard draws two glyphs, and only on a
24
+ terminal that can encode them.) A Windows console gets UTF-16, but REDIRECTED output
25
+ falls back to the machine's codepage, and an em dash is unencodable in cp437, cp850 and
26
+ cp932 — so one decorative character turns `simcon-toolkit init --list > list.txt` into a
27
+ traceback on a Japanese or Korean machine. This is the tool that must refuse nobody.
28
+ * **The catalogue is read late and its failure is caught.** `--help` and `--version` are
29
+ what someone reaches for when a command misbehaves, so they must survive a broken
30
+ installation rather than being the next thing that tracebacks.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import functools
37
+ import sys
38
+ from dataclasses import dataclass
39
+ from pathlib import Path
40
+ from typing import TYPE_CHECKING
41
+
42
+ if TYPE_CHECKING:
43
+ from simcon_toolkit.manifest import TemplateSpec
44
+
45
+ #: Handed to a caller who asked nothing. It is the simplest of the four — one script at its
46
+ #: root — which is the least surprising thing to give someone who supplied no opinion.
47
+ DEFAULT_TEMPLATE = "run-one-simulation"
48
+
49
+ # Imports nothing, so it is safe above the interpreter check.
50
+ from simcon_toolkit.choices import DEFAULT_PACKAGE_MANAGER, PACKAGE_MANAGERS # noqa: E402
51
+
52
+ #: Copying a template out of the installed package is `importlib.resources.as_file()` on a
53
+ #: directory, which is supported only from here.
54
+ MINIMUM_PYTHON = (3, 12)
55
+
56
+ #: The flags that live under `init`, so a command using one without it can be pointed there.
57
+ INIT_ONLY_FLAGS = frozenset(
58
+ {
59
+ "-t",
60
+ "--template",
61
+ "--package-manager",
62
+ "-y",
63
+ "--yes",
64
+ "--no-input",
65
+ "--defaults",
66
+ "-f",
67
+ "--force",
68
+ "-l",
69
+ "--list",
70
+ }
71
+ )
72
+
73
+ #: What argparse prints for a usage error, so one command never speaks in two voices.
74
+ PREFIX = "simcon-toolkit: error:"
75
+
76
+
77
+ class KitUnreadable(Exception):
78
+ """The installed catalogue is absent, incomplete or corrupt — not the caller's doing."""
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class Choices:
83
+ """What the run resolved to, once flags and defaults were applied."""
84
+
85
+ template: str
86
+ name: str
87
+ package_manager: str
88
+
89
+
90
+ def check_interpreter(version: tuple[int, int] | None = None) -> str | None:
91
+ """The refusal to print when this Python is too old, or None when it will do.
92
+
93
+ Both installers enforce the *floor* at resolution, so this is a backstop rather than
94
+ the first line of defence: it covers a run from a source checkout, an install forced
95
+ past the metadata, and a future release that drops the bound. It earns its lines
96
+ because what the installers produce instead is a resolver's unsatisfiable-requirements
97
+ dump, which names this package rather than the thing the reader has to change.
98
+
99
+ Nothing above it in this module may import the manifest: `tomllib` does not exist
100
+ below 3.11, so an import at module scope raises first and this sentence is never
101
+ reached on the versions that need it most.
102
+
103
+ The argument is resolved per call rather than defaulted, for the reason spelled out on
104
+ `is_interactive` — here it could not actually change, but one module should not teach
105
+ the rule and break it four functions apart.
106
+ """
107
+ if version is None:
108
+ version = sys.version_info[:2]
109
+ if version >= MINIMUM_PYTHON:
110
+ return None
111
+ running = ".".join(str(part) for part in version)
112
+ wanted = ".".join(str(part) for part in MINIMUM_PYTHON)
113
+ return (
114
+ f"simcon-toolkit needs Python {wanted} or newer; this is Python {running}. "
115
+ f"Run it as `uvx --python {wanted} simcon-toolkit@latest init`, or install Python {wanted}."
116
+ )
117
+
118
+
119
+ @functools.cache
120
+ def read_templates() -> dict[str, TemplateSpec]:
121
+ """The catalogue, with every way it can be unreadable turned into one refusal.
122
+
123
+ A `_kit/` that EXISTS is not a `_kit/` that works, and only the first is checked when
124
+ the package resolves its root. `force-include` ships whatever is on disk at build time,
125
+ so a present-but-incomplete kit is a shape this project already worries about, and the
126
+ carefully worded message written for a missing kit misses it entirely.
127
+
128
+ Cached, because `--help` reads it for the epilog and the run reads it again; a failure
129
+ raises and is therefore never cached, so a broken kit is reported every time.
130
+ """
131
+ where = "its installed location"
132
+ try:
133
+ from simcon_toolkit.manifest import MANIFEST, load_manifest
134
+
135
+ where = str(MANIFEST)
136
+ return load_manifest()
137
+ # Broad on purpose: a missing file, unreadable TOML and a manifest with no `templates`
138
+ # table are three exceptions with one honest cause and one useful thing to say.
139
+ except Exception as failure:
140
+ raise KitUnreadable(
141
+ f"simcon-toolkit cannot read its own template catalogue at {where} ({failure}). "
142
+ f"The installation is incomplete; reinstall with `uvx simcon-toolkit@latest init`."
143
+ ) from failure
144
+
145
+
146
+ def _catalogue_epilog() -> str:
147
+ """The template names for `--help`, or nothing. Never a reason for `--help` to fail."""
148
+ try:
149
+ return f"Templates: {', '.join(sorted(read_templates()))}."
150
+ except KitUnreadable:
151
+ return ""
152
+
153
+
154
+ def build_parser() -> argparse.ArgumentParser:
155
+ """Every command and flag the installer accepts, and nothing that was designed away."""
156
+ from simcon_toolkit import __version__
157
+
158
+ parser = argparse.ArgumentParser(
159
+ prog="simcon-toolkit",
160
+ description="Put a working SIMCON example project on your machine.",
161
+ )
162
+ parser.add_argument("--version", action="version", version=f"simcon-toolkit {__version__}")
163
+ commands = parser.add_subparsers(dest="command", metavar="<command>")
164
+ init = commands.add_parser(
165
+ "init",
166
+ help="create a project from a template",
167
+ description="Create a project from a template, ready to run.",
168
+ epilog=_catalogue_epilog(),
169
+ )
170
+ init.add_argument("name", nargs="?", help="folder to create (default: the template's own name)")
171
+ init.add_argument(
172
+ "-t",
173
+ "--template",
174
+ help=f"which workflow to start from; the one required answer (--yes picks {DEFAULT_TEMPLATE})",
175
+ )
176
+ init.add_argument(
177
+ "--package-manager",
178
+ choices=PACKAGE_MANAGERS,
179
+ help=f"which installer the project's instructions are written for (default: {DEFAULT_PACKAGE_MANAGER})",
180
+ )
181
+ # The aliases are real flags rather than hidden ones: someone who types what copier or
182
+ # cookiecutter taught them should find it in --help too, not discover it by luck.
183
+ init.add_argument(
184
+ "-y",
185
+ "--yes",
186
+ "--no-input",
187
+ "--defaults",
188
+ action="store_true",
189
+ help="ask nothing and use the documented defaults",
190
+ )
191
+ init.add_argument("-f", "--force", action="store_true", help="overwrite files the template would write")
192
+ init.add_argument("-l", "--list", action="store_true", help="list the templates and exit")
193
+ return parser
194
+
195
+
196
+ def at_a_terminal() -> bool:
197
+ """Whether a person can both see a question and answer it.
198
+
199
+ Both streams, not only stdin: `init > log.txt` at a terminal would otherwise wait on a
200
+ question written into the file, and prompt_toolkit raises on a redirected Windows stdout.
201
+ """
202
+ # A closed descriptor leaves the stream as None rather than raising, so `init <&-` would
203
+ # otherwise die on an AttributeError before it had done anything.
204
+ return bool(sys.stdin and sys.stdout and sys.stdin.isatty() and sys.stdout.isatty())
205
+
206
+
207
+ def is_interactive(args: argparse.Namespace, tty: bool | None = None) -> bool:
208
+ """Whether to open the wizard: someone is there, and the one required answer is missing.
209
+
210
+ Checked before any prompt library is reached, never left to one: every library measured
211
+ blocks forever on an open stdin that nobody writes to, which is what an assistant's
212
+ captured shell usually hands a child process.
213
+
214
+ `tty` is resolved on each call, never as a default argument: a default is evaluated
215
+ once when this module is imported, which for a long-lived import binds whatever stdin
216
+ looked like then and answers the same way forever after.
217
+ """
218
+ if tty is None:
219
+ tty = at_a_terminal()
220
+ return tty and not args.yes and args.template is None
221
+
222
+
223
+ def resolve(args: argparse.Namespace) -> Choices:
224
+ """Flags first, then the documented defaults — and nothing read off this machine.
225
+
226
+ `create-next-app`'s `--yes` consults a stored preference file, which is why it needs a
227
+ flag to reset one and why the same command produces different projects on two laptops.
228
+ Ours resolves to the documented defaults and nothing else.
229
+ """
230
+ template = args.template or DEFAULT_TEMPLATE
231
+ return Choices(
232
+ template=template,
233
+ name=args.name or template,
234
+ package_manager=args.package_manager or DEFAULT_PACKAGE_MANAGER,
235
+ )
236
+
237
+
238
+ def _describe(spec: TemplateSpec) -> str:
239
+ """One catalogue line, ASCII only — see the note on encodings at the top of this file."""
240
+ credentials = " | needs " + ", ".join(spec.needs) if spec.needs else ""
241
+ return f" {spec.name:<20} {spec.title}: {spec.description}{credentials}"
242
+
243
+
244
+ def main(argv: list[str] | None = None) -> int:
245
+ """Parse, validate, and write the project the caller asked for."""
246
+ refusal = check_interpreter()
247
+ if refusal is not None:
248
+ print(refusal, file=sys.stderr)
249
+ return 2
250
+
251
+ # Everything this module writes is ASCII, but a folder name or a kept file is the
252
+ # customer's, and stdout encodes strictly: a redirected run on a Japanese codepage would
253
+ # write the whole project and then die printing its name.
254
+ for stream in (sys.stdout, sys.stderr):
255
+ reconfigure = getattr(stream, "reconfigure", None)
256
+ if reconfigure is not None:
257
+ reconfigure(errors="backslashreplace")
258
+
259
+ if argv is None:
260
+ argv = sys.argv[1:]
261
+ if argv and argv[0] in INIT_ONLY_FLAGS:
262
+ # The flags moved under `init`; a command copied from an older page should be told
263
+ # where they went rather than meet argparse's bare "unrecognized arguments".
264
+ print(f"{PREFIX} {argv[0]} belongs to the init command: simcon-toolkit init {' '.join(argv)}", file=sys.stderr)
265
+ return 2
266
+
267
+ parser = build_parser()
268
+ args = parser.parse_args(argv)
269
+ if args.command is None:
270
+ # A refusal, so the help goes where refusals go; see `test_every_refusal_goes_to_stderr`.
271
+ parser.print_help(sys.stderr)
272
+ return 2
273
+
274
+ try:
275
+ templates = read_templates()
276
+ except KitUnreadable as unreadable:
277
+ print(f"{PREFIX} {unreadable}", file=sys.stderr)
278
+ return 2
279
+
280
+ if args.list:
281
+ print("Templates:")
282
+ for spec in sorted(templates.values(), key=lambda entry: entry.name):
283
+ print(_describe(spec))
284
+ return 0
285
+
286
+ # An empty name is a mistyped one, not an absent one. Without this, `-y ""` resolves to
287
+ # the template's own name and hands back a folder nobody asked for, reporting success.
288
+ if args.name is not None and not args.name.strip():
289
+ print(f"{PREFIX} the project name is empty. Give a folder name, or leave it out entirely.", file=sys.stderr)
290
+ return 2
291
+
292
+ tty = at_a_terminal()
293
+ if args.template is not None and args.template not in templates:
294
+ known = ", ".join(sorted(templates))
295
+ # Named first, because the one thing the reader knows is what they typed. The full
296
+ # list follows rather than a suggestion: four names fit on a line, and a guess that
297
+ # lands on the wrong one costs more than reading them.
298
+ print(f"{PREFIX} no template named {args.template!r}. Available: {known}", file=sys.stderr)
299
+ if not tty or args.yes:
300
+ return 2
301
+ # Someone is there to choose, so the menu is kinder than a second attempt at typing.
302
+ args.template = None
303
+
304
+ if is_interactive(args, tty):
305
+ from simcon_toolkit.wizard import Cancelled, ask, name_problem
306
+
307
+ if args.name is not None:
308
+ # Before the questions, not after them, and by the prompt's own rule: a name that
309
+ # can never work should not cost the reader two answers first.
310
+ problem = name_problem(args.name, force=args.force)
311
+ if problem is not None:
312
+ print(f"{PREFIX} {problem}", file=sys.stderr)
313
+ return 2
314
+
315
+ try:
316
+ template, name, package_manager = ask(
317
+ templates,
318
+ default_template=DEFAULT_TEMPLATE,
319
+ name=args.name,
320
+ package_manager=args.package_manager,
321
+ force=args.force,
322
+ )
323
+ except Cancelled:
324
+ print("Cancelled; nothing was written.", file=sys.stderr)
325
+ return 130
326
+ chosen = Choices(template=template, name=name, package_manager=package_manager)
327
+ elif args.template is None and not args.yes:
328
+ # The refusal is what an assistant reads to correct itself, so it names every way out.
329
+ print(f"{PREFIX} no template chosen, and there is no terminal to ask at.", file=sys.stderr)
330
+ print(
331
+ f"Pass --template <name> (--list shows them), or --yes for {DEFAULT_TEMPLATE} with every default.",
332
+ file=sys.stderr,
333
+ )
334
+ return 2
335
+ else:
336
+ chosen = resolve(args)
337
+
338
+ from simcon_toolkit import KIT_ROOT, __version__
339
+ from simcon_toolkit.generate import GeneratorError, WriteFailed, format_listing, generate_project
340
+
341
+ destination = Path(chosen.name)
342
+ try:
343
+ result = generate_project(
344
+ chosen.template,
345
+ destination,
346
+ force=args.force,
347
+ cli_version=__version__,
348
+ package_manager=chosen.package_manager,
349
+ )
350
+ except GeneratorError as refusal:
351
+ print(f"{PREFIX} {refusal}", file=sys.stderr)
352
+ # A write that failed part-way is not a mistake in the arguments.
353
+ return 1 if isinstance(refusal, WriteFailed) else 2
354
+ except OSError as failure:
355
+ # A file missing from our own kit is a broken installation, and saying "could not
356
+ # write" would send the reader to check their own disk for a fault that is ours.
357
+ if failure.filename and Path(failure.filename).resolve().is_relative_to(KIT_ROOT.resolve()):
358
+ print(f"{PREFIX} the installation is incomplete: {failure.filename} is missing.", file=sys.stderr)
359
+ print("Reinstall with `uvx simcon-toolkit@latest init`.", file=sys.stderr)
360
+ return 2
361
+ # A disk that is full or a folder we may not write to: not the caller's mistake in
362
+ # the arguments, so exit 1 rather than the usage error's 2.
363
+ print(f"{PREFIX} could not write {destination}: {failure.strerror or failure}", file=sys.stderr)
364
+ return 1
365
+
366
+ # Every resolved value, defaulted or not, because the transcript is all an assistant keeps.
367
+ print(
368
+ f"Created {chosen.template} in {destination}, package manager: {chosen.package_manager} "
369
+ f"({len(result.written)} files written)."
370
+ )
371
+ if result.overwritten:
372
+ print(f"Overwrote {len(result.overwritten)} existing file(s): {format_listing(result.overwritten)}")
373
+ if result.kept:
374
+ # Never removed, because only the reader knows whether they are their own work or
375
+ # something left from a different template.
376
+ print(f"Left {len(result.kept)} file(s) that are not part of this template: {format_listing(result.kept)}")
377
+ return 0
378
+
379
+
380
+ if __name__ == "__main__":
381
+ raise SystemExit(main())