cli-wizard 2.0.0__tar.gz → 2.1.0__tar.gz

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 (72) hide show
  1. {cli_wizard-2.0.0/src/cli_wizard.egg-info → cli_wizard-2.1.0}/PKG-INFO +16 -18
  2. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/README.md +1 -1
  3. cli_wizard-2.1.0/VERSION +1 -0
  4. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/pyproject.toml +19 -18
  5. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/cli.py +4 -2
  6. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/commands/bootstrap.py +60 -29
  7. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/commands/common.py +2 -1
  8. cli_wizard-2.1.0/src/cli_wizard/commands/config.py +106 -0
  9. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/commands/generate.py +69 -30
  10. cli_wizard-2.1.0/src/cli_wizard/config/configuration.py +77 -0
  11. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/config/schema.py +131 -9
  12. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/constants.py +1 -1
  13. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/generator/generator.py +104 -19
  14. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/generator/models.py +4 -1
  15. cli_wizard-2.0.0/src/cli_wizard/templates/.github/workflows/changelog_enforcer.yaml → cli_wizard-2.1.0/src/cli_wizard/templates/.github/workflows/changelog-enforcer.yaml +1 -1
  16. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/.github/workflows/codeql.yaml +4 -4
  17. cli_wizard-2.0.0/src/cli_wizard/templates/.github/workflows/docs.yaml → cli_wizard-2.1.0/src/cli_wizard/templates/.github/workflows/docs.yaml.j2 +6 -6
  18. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/.github/workflows/labeler.yaml +1 -1
  19. cli_wizard-2.0.0/src/cli_wizard/templates/.github/workflows/pr-validation.yaml → cli_wizard-2.1.0/src/cli_wizard/templates/.github/workflows/pr-validation.yaml.j2 +5 -5
  20. cli_wizard-2.0.0/src/cli_wizard/templates/.github/workflows/release.yaml → cli_wizard-2.1.0/src/cli_wizard/templates/.github/workflows/release.yaml.j2 +5 -5
  21. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/.github/workflows/sync-labels.yaml +2 -2
  22. cli_wizard-2.1.0/src/cli_wizard/templates/.github/workflows/test.yaml.j2 +46 -0
  23. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/Makefile.j2 +4 -3
  24. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/pre-commit-config.yaml.j2 +7 -11
  25. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/pyproject.toml.j2 +20 -19
  26. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/src/{{ PackageName }}/client.py.j2 +2 -2
  27. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/src/{{ PackageName }}/commands/__init__.py.j2 +1 -1
  28. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/src/{{ PackageName }}/logging.py.j2 +1 -1
  29. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/src/{{ PackageName }}/profile.py.j2 +2 -2
  30. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/tests/{{ PackageName }}/cli_test.py.j2 +2 -4
  31. cli_wizard-2.1.0/src/cli_wizard/templates/tox.ini.j2 +27 -0
  32. {cli_wizard-2.0.0 → cli_wizard-2.1.0/src/cli_wizard.egg-info}/PKG-INFO +16 -18
  33. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard.egg-info/SOURCES.txt +5 -5
  34. cli_wizard-2.1.0/src/cli_wizard.egg-info/requires.txt +24 -0
  35. cli_wizard-2.0.0/VERSION +0 -1
  36. cli_wizard-2.0.0/src/cli_wizard/commands/config.py +0 -82
  37. cli_wizard-2.0.0/src/cli_wizard/config/configuration.py +0 -45
  38. cli_wizard-2.0.0/src/cli_wizard/templates/.github/workflows/test.yaml +0 -46
  39. cli_wizard-2.0.0/src/cli_wizard/templates/tox.ini.j2 +0 -27
  40. cli_wizard-2.0.0/src/cli_wizard.egg-info/requires.txt +0 -26
  41. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/LICENSE +0 -0
  42. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/MANIFEST.in +0 -0
  43. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/setup.cfg +0 -0
  44. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/__init__.py +0 -0
  45. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/commands/__init__.py +0 -0
  46. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/config/__init__.py +0 -0
  47. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/generator/__init__.py +3 -3
  48. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/generator/parser.py +0 -0
  49. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/.github/CODEOWNERS.j2 +0 -0
  50. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/.github/ISSUE_TEMPLATE/bug-report.yml.j2 +0 -0
  51. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/.github/ISSUE_TEMPLATE/config.yml.j2 +0 -0
  52. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/.github/ISSUE_TEMPLATE/feature-request.yml.j2 +0 -0
  53. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  54. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/.github/dependabot.yaml +0 -0
  55. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/.github/labeler.yaml +0 -0
  56. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/.github/labels.yaml.j2 +0 -0
  57. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/.gitignore.j2 +0 -0
  58. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/CHANGELOG.md.j2 +0 -0
  59. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/DEVELOPMENT.md.j2 +0 -0
  60. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/LICENSE.j2 +0 -0
  61. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/MANIFEST.in.j2 +0 -0
  62. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/README.md.j2 +0 -0
  63. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/VERSION.j2 +0 -0
  64. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/cli-wizard.yaml.j2 +0 -0
  65. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/src/{{ PackageName }}/__init__.py.j2 +0 -0
  66. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/src/{{ PackageName }}/cli.py.j2 +0 -0
  67. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/src/{{ PackageName }}/commands/config.py.j2 +0 -0
  68. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/src/{{ PackageName }}/commands/group.py.j2 +0 -0
  69. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/src/{{ PackageName }}/constants.py.j2 +0 -0
  70. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard.egg-info/dependency_links.txt +0 -0
  71. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard.egg-info/entry_points.txt +0 -0
  72. {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli-wizard
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: Generate modern CLIs from OpenAPI specifications
5
5
  Author-email: Giacomo Marciani <giacomo.marciani@gmail.com>
6
6
  License: MIT
@@ -17,24 +17,22 @@ Classifier: Programming Language :: Python :: 3.14
17
17
  Requires-Python: >=3.12
18
18
  Description-Content-Type: text/markdown
19
19
  License-File: LICENSE
20
- Requires-Dist: click~=8.3.1
21
- Requires-Dist: Jinja2~=3.1.6
22
- Requires-Dist: pydantic~=2.12.5
23
- Requires-Dist: PyYAML~=6.0.3
24
- Requires-Dist: requests~=2.32.5
20
+ Requires-Dist: click~=8.4
21
+ Requires-Dist: Jinja2~=3.1
22
+ Requires-Dist: pydantic~=2.13
23
+ Requires-Dist: PyYAML~=6.0
24
+ Requires-Dist: requests~=2.34
25
+ Requires-Dist: ruff~=0.16.2
25
26
  Provides-Extra: dev
26
- Requires-Dist: autoflake~=2.3; extra == "dev"
27
- Requires-Dist: black~=26.1; extra == "dev"
28
- Requires-Dist: build~=1.4; extra == "dev"
29
- Requires-Dist: flake8~=7.3; extra == "dev"
30
- Requires-Dist: mypy~=1.19; extra == "dev"
31
- Requires-Dist: pre-commit~=4.5; extra == "dev"
32
- Requires-Dist: pytest~=9.0; extra == "dev"
33
- Requires-Dist: pytest-cov~=7.0; extra == "dev"
34
- Requires-Dist: tox~=4.34; extra == "dev"
35
- Requires-Dist: twine~=6.2; extra == "dev"
27
+ Requires-Dist: build~=1.5; extra == "dev"
28
+ Requires-Dist: mypy~=2.3; extra == "dev"
29
+ Requires-Dist: pre-commit~=4.6; extra == "dev"
30
+ Requires-Dist: pytest~=9.1; extra == "dev"
31
+ Requires-Dist: pytest-cov~=7.1; extra == "dev"
32
+ Requires-Dist: tox~=4.58; extra == "dev"
33
+ Requires-Dist: twine<8.0,>=6.2; extra == "dev"
36
34
  Requires-Dist: types-PyYAML~=6.0; extra == "dev"
37
- Requires-Dist: types-requests~=2.32; extra == "dev"
35
+ Requires-Dist: types-requests~=2.33; extra == "dev"
38
36
  Provides-Extra: docs
39
37
  Requires-Dist: sphinx~=9.1; extra == "docs"
40
38
  Requires-Dist: sphinx-click~=6.2; extra == "docs"
@@ -54,7 +52,7 @@ Dynamic: license-file
54
52
  [![Build status](https://img.shields.io/github/actions/workflow/status/gmarciani/cli-wizard/test.yaml?branch=main)](https://github.com/gmarciani/cli-wizard/actions)
55
53
  [![Tests](https://img.shields.io/github/actions/workflow/status/gmarciani/cli-wizard/test.yaml?branch=main&label=tests)](https://github.com/gmarciani/cli-wizard/actions)
56
54
  [![Coverage](https://img.shields.io/codecov/c/github/gmarciani/cli-wizard)](https://codecov.io/gh/gmarciani/cli-wizard)
57
- [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
55
+ [![Code style: ruff](https://img.shields.io/badge/code%20style-ruff-261230.svg)](https://github.com/astral-sh/ruff)
58
56
  [![Downloads](https://img.shields.io/pypi/dm/cli-wizard.svg)](https://pypi.org/project/cli-wizard)
59
57
 
60
58
  </div>
@@ -9,7 +9,7 @@
9
9
  [![Build status](https://img.shields.io/github/actions/workflow/status/gmarciani/cli-wizard/test.yaml?branch=main)](https://github.com/gmarciani/cli-wizard/actions)
10
10
  [![Tests](https://img.shields.io/github/actions/workflow/status/gmarciani/cli-wizard/test.yaml?branch=main&label=tests)](https://github.com/gmarciani/cli-wizard/actions)
11
11
  [![Coverage](https://img.shields.io/codecov/c/github/gmarciani/cli-wizard)](https://codecov.io/gh/gmarciani/cli-wizard)
12
- [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
12
+ [![Code style: ruff](https://img.shields.io/badge/code%20style-ruff-261230.svg)](https://github.com/astral-sh/ruff)
13
13
  [![Downloads](https://img.shields.io/pypi/dm/cli-wizard.svg)](https://pypi.org/project/cli-wizard)
14
14
 
15
15
  </div>
@@ -0,0 +1 @@
1
+ 2.1.0
@@ -22,27 +22,25 @@ classifiers = [
22
22
  ]
23
23
  requires-python = ">=3.12"
24
24
  dependencies = [
25
- "click~=8.3.1",
26
- "Jinja2~=3.1.6",
27
- "pydantic~=2.12.5",
28
- "PyYAML~=6.0.3",
29
- "requests~=2.32.5",
25
+ "click~=8.4",
26
+ "Jinja2~=3.1",
27
+ "pydantic~=2.13",
28
+ "PyYAML~=6.0",
29
+ "requests~=2.34",
30
+ "ruff~=0.16.2",
30
31
  ]
31
32
 
32
33
  [project.optional-dependencies]
33
34
  dev = [
34
- "autoflake~=2.3",
35
- "black~=26.1",
36
- "build~=1.4",
37
- "flake8~=7.3",
38
- "mypy~=1.19",
39
- "pre-commit~=4.5",
40
- "pytest~=9.0",
41
- "pytest-cov~=7.0",
42
- "tox~=4.34",
43
- "twine~=6.2",
35
+ "build~=1.5",
36
+ "mypy~=2.3",
37
+ "pre-commit~=4.6",
38
+ "pytest~=9.1",
39
+ "pytest-cov~=7.1",
40
+ "tox~=4.58",
41
+ "twine>=6.2,<8.0",
44
42
  "types-PyYAML~=6.0",
45
- "types-requests~=2.32",
43
+ "types-requests~=2.33",
46
44
  ]
47
45
  docs = [
48
46
  "sphinx~=9.1",
@@ -76,9 +74,12 @@ cli_wizard = [
76
74
  [tool.setuptools.dynamic]
77
75
  version = {file = "VERSION"}
78
76
 
79
- [tool.black]
77
+ [tool.ruff]
80
78
  line-length = 88
81
- target-version = ['py312']
79
+ target-version = "py312"
80
+
81
+ [tool.ruff.lint]
82
+ select = ["E", "F", "W", "I"]
82
83
 
83
84
  [tool.mypy]
84
85
  python_version = "3.12"
@@ -3,12 +3,14 @@
3
3
 
4
4
  """Main CLI module for CLI Wizard."""
5
5
 
6
- import click
7
6
  import logging
8
- from cli_wizard.constants import __version__
7
+
8
+ import click
9
+
9
10
  from cli_wizard.commands.bootstrap import bootstrap
10
11
  from cli_wizard.commands.config import config
11
12
  from cli_wizard.commands.generate import generate
13
+ from cli_wizard.constants import __version__
12
14
 
13
15
 
14
16
  @click.group(help="CLI Wizard - Generate modern CLI from OpenAPI.")
@@ -6,8 +6,8 @@
6
6
  import getpass
7
7
  import logging
8
8
  import re
9
- from pathlib import Path
10
9
  from datetime import date
10
+ from pathlib import Path
11
11
  from typing import Any
12
12
 
13
13
  import click
@@ -22,6 +22,10 @@ from cli_wizard.generator import CliGenerator
22
22
  logger = logging.getLogger(__name__)
23
23
 
24
24
 
25
+ # Width beyond which PyYAML would wrap a scalar onto a second line
26
+ YAML_NO_WRAP_WIDTH = 2**31 - 1
27
+
28
+
25
29
  # Parameters prompted during bootstrap (in order)
26
30
  BOOTSTRAP_PARAMS: list[str] = [
27
31
  "CommandName",
@@ -115,7 +119,8 @@ You will be guided through a step by step procedure to generate
115
119
  a basic CLI and an extensible configuration file to evolve it.
116
120
  No OpenAPI file is required.
117
121
 
118
- PATH is the directory where the project will be created. It can be a relative or absolute path."""
122
+ PATH is the directory where the project will be created.
123
+ It can be a relative or absolute path."""
119
124
  )
120
125
  @click.argument(
121
126
  "path",
@@ -253,13 +258,17 @@ def _yaml_value(value: Any) -> str:
253
258
  elif isinstance(value, bool):
254
259
  return "true" if value else "false"
255
260
  elif isinstance(value, str):
256
- # Quote strings that might be ambiguous
257
- if value == "" or value in ("true", "false", "null", "yes", "no"):
258
- return f'"{value}"'
259
- # Quote strings with special characters
260
- if any(c in value for c in ":#{}[]&*!|>'\"%@`"):
261
- return f'"{value}"'
262
- return f'"{value}"'
261
+ # Always double-quoted, and escaped by PyYAML rather than by hand, so
262
+ # that quotes, backslashes and control characters survive the round
263
+ # trip through the file. The template writes one value per line, so
264
+ # line wrapping is disabled.
265
+ return yaml.safe_dump(
266
+ value,
267
+ default_style='"',
268
+ default_flow_style=True,
269
+ allow_unicode=True,
270
+ width=YAML_NO_WRAP_WIDTH,
271
+ ).strip()
263
272
  elif isinstance(value, (int, float)):
264
273
  return str(value)
265
274
  elif isinstance(value, list):
@@ -321,32 +330,54 @@ def _load_cli_config(config_path: Path) -> dict:
321
330
  raise SystemExit(1)
322
331
 
323
332
  # Expand #[Param] references
324
- return _expand_config_references(config)
333
+ try:
334
+ return _expand_config_references(config)
335
+ except ValueError as e:
336
+ click.secho("✗ Invalid configuration:", fg="red", err=True)
337
+ click.secho(f" • {e}", fg="red", err=True)
338
+ raise SystemExit(1)
325
339
 
326
340
 
327
341
  def _expand_config_references(config: dict[str, Any]) -> dict[str, Any]:
328
- """Expand #[Param] references in config values recursively."""
342
+ """Expand #[Param] references in config values recursively.
343
+
344
+ Supports referencing other config parameters using #[ParamName] syntax.
345
+ Environment variables using ${VAR} syntax are left as-is for runtime expansion.
346
+ References to unknown or non-string parameters are left as-is. A parameter
347
+ that references itself, directly or through other parameters, raises a
348
+ ValueError rather than expanding forever.
349
+ """
350
+ pattern = re.compile(r"#\[(\w+)\]")
351
+ resolved: dict[str, str] = {}
352
+
353
+ def resolve(name: str, chain: tuple[str, ...]) -> str:
354
+ """Resolve a top-level parameter, refusing to expand it into itself."""
355
+ if name in chain:
356
+ path = " -> ".join(chain + (name,))
357
+ raise ValueError(
358
+ f"Circular #[Param] reference in configuration: {path} "
359
+ f'(value of {name!r}: "{config[name]}")'
360
+ )
361
+ if name not in resolved:
362
+ resolved[name] = substitute(config[name], chain + (name,))
363
+ return resolved[name]
364
+
365
+ def substitute(value: str, chain: tuple[str, ...]) -> str:
366
+ def replace(match: re.Match[str]) -> str:
367
+ param_name = match.group(1)
368
+ if isinstance(config.get(param_name), str):
369
+ return resolve(param_name, chain)
370
+ return match.group(0)
371
+
372
+ return pattern.sub(replace, value)
329
373
 
330
- def expand_value(value: Any, cfg: dict[str, Any]) -> Any:
374
+ def expand_value(value: Any) -> Any:
331
375
  if isinstance(value, str):
332
- pattern = r"#\[(\w+)\]"
333
- prev_value: str | None = None
334
- while prev_value != value:
335
- prev_value = value
336
- matches = re.findall(pattern, value)
337
- for param_name in matches:
338
- if param_name in cfg:
339
- replacement = cfg[param_name]
340
- if isinstance(replacement, str):
341
- value = value.replace(f"#[{param_name}]", replacement)
342
- return value
376
+ return substitute(value, ())
343
377
  elif isinstance(value, dict):
344
- return {k: expand_value(v, cfg) for k, v in value.items()}
378
+ return {k: expand_value(v) for k, v in value.items()}
345
379
  elif isinstance(value, list):
346
- return [expand_value(item, cfg) for item in value]
380
+ return [expand_value(item) for item in value]
347
381
  return value
348
382
 
349
- expanded = expand_value(config, config)
350
- result = expand_value(expanded, expanded)
351
- # expand_value always returns a dict when given a dict
352
- return dict(result) if isinstance(result, dict) else config
383
+ return {key: expand_value(value) for key, value in config.items()}
@@ -3,9 +3,10 @@
3
3
 
4
4
  """Common utilities for CLI Wizard commands."""
5
5
 
6
- import click
7
6
  import logging
8
7
 
8
+ import click
9
+
9
10
  logger = logging.getLogger(__name__)
10
11
 
11
12
 
@@ -0,0 +1,106 @@
1
+ # Copyright (c) 2026, Giacomo Marciani
2
+ # Licensed under the MIT License
3
+
4
+ """Configuration commands for CLI Wizard."""
5
+
6
+ import json
7
+ import logging
8
+
9
+ import click
10
+ from pydantic import ValidationError
11
+
12
+ from cli_wizard.config.configuration import (
13
+ get_config_path,
14
+ load_config,
15
+ load_stored_config,
16
+ save_config,
17
+ )
18
+ from cli_wizard.config.schema import Config
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def _check_known_key(key: str) -> None:
24
+ """Abort if the key is not a configuration field."""
25
+ if key not in Config.model_fields:
26
+ raise click.ClickException(f"Unknown configuration key '{key}'")
27
+
28
+
29
+ @click.group(help="Manage configurations.")
30
+ def config() -> None:
31
+ """Config command group implementation."""
32
+
33
+
34
+ @config.command(help="Set a configuration value.")
35
+ @click.argument("key")
36
+ @click.argument("value")
37
+ def set(key: str, value: str) -> None:
38
+ """Set command implementation."""
39
+ _check_known_key(key)
40
+
41
+ stored = load_stored_config()
42
+ old_value = load_config().get(key)
43
+
44
+ try:
45
+ stored[key] = Config.parse_cli_value(key, value)
46
+ save_config(stored)
47
+ except ValidationError as e:
48
+ detail = "; ".join(item["msg"] for item in e.errors())
49
+ raise click.ClickException(f"Invalid value for '{key}': {detail}") from e
50
+ except ValueError as e:
51
+ raise click.ClickException(f"Invalid value for '{key}': {e}") from e
52
+
53
+ result = {"key": key, "value": load_config()[key], "oldValue": old_value}
54
+ print(json.dumps(result, indent=2))
55
+
56
+
57
+ @config.command(help="Get a configuration value.")
58
+ @click.argument("key")
59
+ def get(key: str) -> None:
60
+ """Get command implementation."""
61
+ _check_known_key(key)
62
+
63
+ result = {"key": key, "value": load_config()[key]}
64
+ print(json.dumps(result, indent=2))
65
+
66
+
67
+ @config.command(help="Unset a configuration value, reverting it to the default.")
68
+ @click.argument("key")
69
+ def unset(key: str) -> None:
70
+ """Unset command implementation.
71
+
72
+ Removes the key so the schema default applies again. Every field has a
73
+ default, so this cannot leave a config the schema rejects.
74
+ """
75
+ _check_known_key(key)
76
+
77
+ stored = load_stored_config()
78
+ if key in stored:
79
+ old_value = stored.pop(key)
80
+ save_config(stored)
81
+ else:
82
+ logger.info(f"'{key}' is not set; it already uses the schema default.")
83
+ old_value = None
84
+
85
+ result = {"key": key, "value": load_config()[key], "oldValue": old_value}
86
+ print(json.dumps(result, indent=2))
87
+
88
+
89
+ @config.command(help="Show all configuration values as JSON.")
90
+ def show() -> None:
91
+ """Show command implementation."""
92
+ print(json.dumps(load_config(), indent=2))
93
+
94
+
95
+ @config.command(help="Reset configuration to defaults and delete local config file.")
96
+ def reset() -> None:
97
+ """Reset command implementation.
98
+
99
+ Deletes before reading: this is the way back from a config file the schema
100
+ rejects, so it must not depend on that file being loadable.
101
+ """
102
+ config_path = get_config_path()
103
+ if config_path.exists():
104
+ config_path.unlink()
105
+
106
+ print(json.dumps(Config().model_dump(), indent=2))
@@ -3,31 +3,33 @@
3
3
 
4
4
  """Generate command for CLI Wizard."""
5
5
 
6
- import re
7
- from typing import Any
8
-
9
- import click
10
6
  import logging
7
+ import re
11
8
  import shutil
12
9
  from pathlib import Path
10
+ from typing import Any
13
11
 
12
+ import click
14
13
  import yaml
15
14
  from pydantic import ValidationError
16
15
 
17
16
  from cli_wizard.config.schema import Config
18
17
  from cli_wizard.constants import CONFIG_FILE_NAME
19
- from cli_wizard.generator import OpenApiParser, CliGenerator
18
+ from cli_wizard.generator import CliGenerator, OpenApiParser
19
+ from cli_wizard.generator.generator import RuffNotFoundError, resolve_ruff
20
20
 
21
21
  logger = logging.getLogger(__name__)
22
22
 
23
23
 
24
- @click.command(help="""Generate the CLI from config and OpenAPI spec.
24
+ @click.command(
25
+ help="""Generate the CLI from config and OpenAPI spec.
25
26
 
26
27
  PATH is the output directory where the CLI project will be generated.
27
28
  It can be a relative or absolute path.
28
29
 
29
30
  If --api is provided, API commands will be generated from the OpenAPI spec.
30
- Otherwise, a functional CLI is generated without API commands.""")
31
+ Otherwise, a functional CLI is generated without API commands."""
32
+ )
31
33
  @click.argument(
32
34
  "path",
33
35
  type=click.Path(file_okay=False, resolve_path=True),
@@ -47,12 +49,19 @@ Otherwise, a functional CLI is generated without API commands.""")
47
49
  default=None,
48
50
  help="Path to OpenAPI spec file in YAML or JSON format (optional)",
49
51
  )
52
+ @click.option(
53
+ "--force",
54
+ "-f",
55
+ is_flag=True,
56
+ help="Skip confirmation prompt if output directory exists and is not empty",
57
+ )
50
58
  @click.pass_context
51
59
  def generate(
52
60
  ctx: click.Context,
53
61
  path: str,
54
62
  configuration: str,
55
63
  api: str | None,
64
+ force: bool,
56
65
  ) -> None:
57
66
  """Generate command implementation."""
58
67
  debug = ctx.obj.get("debug", False) if ctx.obj else False
@@ -116,6 +125,13 @@ def generate(
116
125
  fg="cyan",
117
126
  )
118
127
 
128
+ # Verify the formatter before deleting the previous output
129
+ try:
130
+ resolve_ruff()
131
+ except RuffNotFoundError as e:
132
+ click.secho(f"✗ {e}", fg="red", err=True)
133
+ raise SystemExit(1)
134
+
119
135
  # Clean up output directory before generating
120
136
  if output_path.exists():
121
137
  # Check if we're inside the output directory
@@ -132,6 +148,15 @@ def generate(
132
148
  except OSError:
133
149
  # Current directory may already be deleted
134
150
  pass
151
+
152
+ # Confirm before destroying a directory that holds work
153
+ if not force and any(output_path.iterdir()):
154
+ click.confirm(
155
+ f"⚠️ Output directory '{output_path}' is not empty. "
156
+ "Its entire contents will be deleted. Continue?",
157
+ abort=True,
158
+ )
159
+
135
160
  click.secho("🧹 Cleaning output directory: ", fg="cyan", nl=False)
136
161
  click.echo(output_path)
137
162
  shutil.rmtree(output_path)
@@ -185,7 +210,12 @@ def _load_cli_config(config_path: Path) -> dict:
185
210
  raise SystemExit(1)
186
211
 
187
212
  # Expand #[Param] references
188
- return _expand_config_references(config)
213
+ try:
214
+ return _expand_config_references(config)
215
+ except ValueError as e:
216
+ click.secho("✗ Invalid configuration:", fg="red", err=True)
217
+ click.secho(f" • {e}", fg="red", err=True)
218
+ raise SystemExit(1)
189
219
 
190
220
 
191
221
  def _expand_config_references(config: dict[str, Any]) -> dict[str, Any]:
@@ -193,32 +223,41 @@ def _expand_config_references(config: dict[str, Any]) -> dict[str, Any]:
193
223
 
194
224
  Supports referencing other config parameters using #[ParamName] syntax.
195
225
  Environment variables using ${VAR} syntax are left as-is for runtime expansion.
196
- Recursively expands until no more references remain.
226
+ References to unknown or non-string parameters are left as-is. A parameter
227
+ that references itself, directly or through other parameters, raises a
228
+ ValueError rather than expanding forever.
197
229
  """
230
+ pattern = re.compile(r"#\[(\w+)\]")
231
+ resolved: dict[str, str] = {}
232
+
233
+ def resolve(name: str, chain: tuple[str, ...]) -> str:
234
+ """Resolve a top-level parameter, refusing to expand it into itself."""
235
+ if name in chain:
236
+ path = " -> ".join(chain + (name,))
237
+ raise ValueError(
238
+ f"Circular #[Param] reference in configuration: {path} "
239
+ f'(value of {name!r}: "{config[name]}")'
240
+ )
241
+ if name not in resolved:
242
+ resolved[name] = substitute(config[name], chain + (name,))
243
+ return resolved[name]
244
+
245
+ def substitute(value: str, chain: tuple[str, ...]) -> str:
246
+ def replace(match: re.Match[str]) -> str:
247
+ param_name = match.group(1)
248
+ if isinstance(config.get(param_name), str):
249
+ return resolve(param_name, chain)
250
+ return match.group(0)
198
251
 
199
- def expand_value(value: Any, config: dict[str, Any]) -> Any:
252
+ return pattern.sub(replace, value)
253
+
254
+ def expand_value(value: Any) -> Any:
200
255
  if isinstance(value, str):
201
- # Keep expanding until no more #[Param] references
202
- pattern = r"#\[(\w+)\]"
203
- prev_value: str | None = None
204
- while prev_value != value:
205
- prev_value = value
206
- matches = re.findall(pattern, value)
207
- for param_name in matches:
208
- if param_name in config:
209
- replacement = config[param_name]
210
- if isinstance(replacement, str):
211
- value = value.replace(f"#[{param_name}]", replacement)
212
- return value
256
+ return substitute(value, ())
213
257
  elif isinstance(value, dict):
214
- return {k: expand_value(v, config) for k, v in value.items()}
258
+ return {k: expand_value(v) for k, v in value.items()}
215
259
  elif isinstance(value, list):
216
- return [expand_value(item, config) for item in value]
260
+ return [expand_value(item) for item in value]
217
261
  return value
218
262
 
219
- # First pass: expand all values
220
- expanded: dict[str, Any] = expand_value(config, config)
221
-
222
- # Second pass: re-expand with updated config to handle nested references
223
- result: dict[str, Any] = expand_value(expanded, expanded)
224
- return result
263
+ return {key: expand_value(value) for key, value in config.items()}
@@ -0,0 +1,77 @@
1
+ # Copyright (c) 2026, Giacomo Marciani
2
+ # Licensed under the MIT License
3
+
4
+ """Configuration utilities for CLI Wizard.
5
+
6
+ ``load_stored_config`` returns only the keys the user explicitly set, and that
7
+ is what gets persisted. ``load_config`` returns those merged over the schema
8
+ defaults with derivations applied, and that is what consumers read. Keeping
9
+ them apart is what lets a later ``ProjectName`` change re-derive
10
+ ``CommandName`` instead of being pinned by a value that was only ever a
11
+ default.
12
+ """
13
+
14
+ import logging
15
+ from pathlib import Path
16
+ from typing import Any, Dict
17
+
18
+ import yaml
19
+
20
+ from cli_wizard.config.schema import Config
21
+ from cli_wizard.constants import CONFIG_FILE_NAME
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def get_config_path() -> Path:
27
+ """Get the configuration file path."""
28
+ config_dir = Path.home() / ".cli_wizard"
29
+ config_dir.mkdir(exist_ok=True)
30
+ return config_dir / CONFIG_FILE_NAME
31
+
32
+
33
+ def load_stored_config() -> Dict[str, Any]:
34
+ """Load only the values explicitly stored in the config file.
35
+
36
+ A file that cannot be read, parsed or validated degrades to an empty
37
+ config with a warning, so it can never lock the user out of the commands
38
+ that would repair it.
39
+ """
40
+ config_path = get_config_path()
41
+
42
+ if not config_path.exists():
43
+ return {}
44
+
45
+ try:
46
+ with open(config_path, "r") as f:
47
+ stored = yaml.safe_load(f)
48
+ if not isinstance(stored, dict):
49
+ raise ValueError("file does not contain a mapping")
50
+ Config(**stored) # Validate only; load_config() builds the merged view.
51
+ return stored
52
+ except (yaml.YAMLError, IOError, ValueError) as e:
53
+ # Pydantic's ValidationError is a ValueError.
54
+ logger.warning(
55
+ f"Ignoring invalid configuration file '{config_path}' ({e}). "
56
+ "Run 'cli-wizard config reset' to recreate it."
57
+ )
58
+ return {}
59
+
60
+
61
+ def load_config() -> Dict[str, Any]:
62
+ """Load configuration from file, using schema defaults for missing values."""
63
+ return Config(**load_stored_config()).model_dump()
64
+
65
+
66
+ def save_config(config: Dict[str, Any]) -> None:
67
+ """Save configuration to file, validating and coercing it first.
68
+
69
+ Only the keys present in ``config`` are persisted: writing back the full
70
+ merged dump would freeze derived values such as ``CommandName``.
71
+ """
72
+ validated = Config(**config)
73
+ stored = {key: getattr(validated, key) for key in config}
74
+
75
+ config_path = get_config_path()
76
+ with open(config_path, "w") as f:
77
+ yaml.safe_dump(stored, f, indent=2)