pyscaf-core 0.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 (28) hide show
  1. pyscaf_core-0.1.0/.gitignore +26 -0
  2. pyscaf_core-0.1.0/PKG-INFO +10 -0
  3. pyscaf_core-0.1.0/pyproject.toml +19 -0
  4. pyscaf_core-0.1.0/src/pyscaf_core/__init__.py +15 -0
  5. pyscaf_core-0.1.0/src/pyscaf_core/actions/__init__.py +213 -0
  6. pyscaf_core-0.1.0/src/pyscaf_core/actions/manager.py +176 -0
  7. pyscaf_core-0.1.0/src/pyscaf_core/cli.py +211 -0
  8. pyscaf_core-0.1.0/src/pyscaf_core/preference_chain/__init__.py +82 -0
  9. pyscaf_core-0.1.0/src/pyscaf_core/preference_chain/chain.py +163 -0
  10. pyscaf_core-0.1.0/src/pyscaf_core/preference_chain/circular_dependency_error.py +2 -0
  11. pyscaf_core-0.1.0/src/pyscaf_core/preference_chain/dependency_loader.py +63 -0
  12. pyscaf_core-0.1.0/src/pyscaf_core/preference_chain/model.py +37 -0
  13. pyscaf_core-0.1.0/src/pyscaf_core/preference_chain/tree_walker.py +60 -0
  14. pyscaf_core-0.1.0/src/pyscaf_core/py.typed +0 -0
  15. pyscaf_core-0.1.0/src/pyscaf_core/tools/__init__.py +0 -0
  16. pyscaf_core-0.1.0/src/pyscaf_core/tools/format_toml.py +20 -0
  17. pyscaf_core-0.1.0/src/pyscaf_core/tools/toml_merge.py +30 -0
  18. pyscaf_core-0.1.0/tests/actions/__init__.py +0 -0
  19. pyscaf_core-0.1.0/tests/actions/test_actions.py +131 -0
  20. pyscaf_core-0.1.0/tests/actions/test_manager.py +202 -0
  21. pyscaf_core-0.1.0/tests/cli/__init__.py +0 -0
  22. pyscaf_core-0.1.0/tests/cli/test_cli.py +98 -0
  23. pyscaf_core-0.1.0/tests/preference_chain/__init__.py +0 -0
  24. pyscaf_core-0.1.0/tests/preference_chain/test_execution_order.py +153 -0
  25. pyscaf_core-0.1.0/tests/test_version.py +4 -0
  26. pyscaf_core-0.1.0/tests/tools/__init__.py +0 -0
  27. pyscaf_core-0.1.0/tests/tools/test_format_toml.py +22 -0
  28. pyscaf_core-0.1.0/tests/tools/test_toml_merge.py +76 -0
@@ -0,0 +1,26 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ *.egg-info/
7
+ *.egg
8
+ dist/
9
+ build/
10
+
11
+ # Virtual environments
12
+ .venv/
13
+
14
+ # Tools cache
15
+ .ruff_cache/
16
+ .pytest_cache/
17
+ .mypy_cache/
18
+
19
+ # IDE
20
+ .idea/
21
+ *.swp
22
+ *.swo
23
+
24
+ # OS
25
+ .DS_Store
26
+ Thumbs.db
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyscaf-core
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.12
5
+ Requires-Dist: click>=8.0
6
+ Requires-Dist: pydantic>=2.0
7
+ Requires-Dist: pyyaml>=6.0
8
+ Requires-Dist: questionary>=2.0
9
+ Requires-Dist: rich>=13.0
10
+ Requires-Dist: tomlkit>=0.12
@@ -0,0 +1,19 @@
1
+ [project]
2
+ name = "pyscaf-core"
3
+ version = "0.1.0"
4
+ requires-python = ">=3.12"
5
+ dependencies = [
6
+ "pydantic>=2.0",
7
+ "pyyaml>=6.0",
8
+ "tomlkit>=0.12",
9
+ "click>=8.0",
10
+ "questionary>=2.0",
11
+ "rich>=13.0",
12
+ ]
13
+
14
+ [build-system]
15
+ requires = ["hatchling>=1.26.0"]
16
+ build-backend = "hatchling.build"
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["src/pyscaf_core"]
@@ -0,0 +1,15 @@
1
+ __all__ = [
2
+ "__version__",
3
+ "Action",
4
+ "ActionManager",
5
+ "CLIOption",
6
+ "ChoiceOption",
7
+ "build_cli",
8
+ "cli_option_to_key",
9
+ "make_main",
10
+ ]
11
+ __version__ = "0.1.0"
12
+
13
+ from pyscaf_core.actions import Action, ChoiceOption, CLIOption, cli_option_to_key
14
+ from pyscaf_core.actions.manager import ActionManager
15
+ from pyscaf_core.cli import build_cli, make_main
@@ -0,0 +1,213 @@
1
+ """Action classes for project scaffolding — core engine."""
2
+
3
+ import importlib
4
+ import logging
5
+ import os
6
+ import pkgutil
7
+ from collections.abc import Callable
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from pydantic import BaseModel
12
+
13
+ from pyscaf_core.tools.format_toml import format_toml
14
+ from pyscaf_core.tools.toml_merge import merge_toml_files
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class ChoiceOption(BaseModel):
20
+ """Represents a choice option with different display formats for CLI and interactive modes."""
21
+
22
+ key: str
23
+ display: str
24
+ value: Any
25
+
26
+
27
+ class CLIOption(BaseModel):
28
+ name: str
29
+ type: str = "str"
30
+ help: str | None = None
31
+ default: Any = None
32
+ prompt: str | None = None
33
+ choices: list[ChoiceOption] | None = None
34
+ is_flag: bool | None = None
35
+ multiple: bool | None = None
36
+ required: bool | None = None
37
+ postfill_hook: Callable[[dict[str, str]], dict[str, str]] | None = None
38
+ visible_when: Callable[[dict], bool] | None = None
39
+
40
+ model_config = {"arbitrary_types_allowed": True}
41
+
42
+ def get_choice_keys(self) -> list[str]:
43
+ if self.choices and isinstance(self.choices[0], ChoiceOption):
44
+ return [choice.key for choice in self.choices]
45
+ return []
46
+
47
+ def get_choice_displays(self) -> list[str]:
48
+ if self.choices and isinstance(self.choices[0], ChoiceOption):
49
+ return [choice.display for choice in self.choices]
50
+ return []
51
+
52
+ def get_choice_values(self) -> list[Any]:
53
+ if self.choices and isinstance(self.choices[0], ChoiceOption):
54
+ return [choice.value for choice in self.choices]
55
+ return []
56
+
57
+ def get_choice_by_key(self, key: str) -> Any | None:
58
+ if self.choices and isinstance(self.choices[0], ChoiceOption):
59
+ for choice in self.choices:
60
+ if choice.key == key:
61
+ return choice.value
62
+ return None
63
+
64
+ def get_choice_by_display(self, display: str) -> Any | None:
65
+ if self.choices and isinstance(self.choices[0], ChoiceOption):
66
+ for choice in self.choices:
67
+ if choice.display == display:
68
+ return choice.value
69
+ return None
70
+
71
+ def get_default_display(self) -> str | None:
72
+ if self.type == "choice" and self.choices and isinstance(self.default, int):
73
+ if 0 <= self.default < len(self.choices):
74
+ if isinstance(self.choices[0], ChoiceOption):
75
+ return self.choices[self.default].display
76
+ return None
77
+
78
+ def get_default_value(self) -> Any:
79
+ if self.type == "choice" and self.choices and isinstance(self.default, int):
80
+ if 0 <= self.default < len(self.choices):
81
+ if isinstance(self.choices[0], ChoiceOption):
82
+ return self.choices[self.default].value
83
+ else:
84
+ return self.choices[self.default]
85
+ return self.default
86
+
87
+
88
+ class Action:
89
+ """Abstract base class for all project actions.
90
+
91
+ Actions can:
92
+ 1. Generate file/directory skeleton via the skeleton() method
93
+ 2. Initialize content/behavior via the init() method
94
+ 3. Install dependencies via the install() method
95
+ """
96
+
97
+ depends: set[str] = set()
98
+ run_preferably_after: str | None = None
99
+ cli_options: list[CLIOption] = []
100
+
101
+ def __init_subclass__(cls) -> None:
102
+ if hasattr(cls, "depends") and len(cls.depends) > 1 and not getattr(cls, "run_preferably_after", None):
103
+ raise ValueError(f"Action '{cls.__name__}' has multiple depends but no run_preferably_after")
104
+
105
+ def __init__(self, project_path: str | Path):
106
+ self.project_path = Path(project_path)
107
+
108
+ def skeleton(self, context: dict) -> dict[Path, str | None]:
109
+ """Define the filesystem skeleton for this action.
110
+
111
+ Returns a dictionary mapping paths to content:
112
+ - None -> directory
113
+ - str -> file with that content
114
+ """
115
+ return {}
116
+
117
+ def init(self, context: dict) -> None:
118
+ """Default: merges config.toml from the action's directory into pyproject.toml."""
119
+ module = importlib.import_module(self.__class__.__module__)
120
+ module_file = module.__file__
121
+ if not module_file:
122
+ raise RuntimeError(f"Module {module} has no __file__ attribute")
123
+ action_dir = Path(module_file).parent
124
+ config_path = action_dir / "config.toml"
125
+ pyproject_path = self.project_path / "pyproject.toml"
126
+ if config_path.exists():
127
+ merge_toml_files(input_path=config_path, output_path=pyproject_path)
128
+ format_toml(pyproject_path)
129
+ logger.info("Merged %s into %s", config_path, pyproject_path)
130
+
131
+ def install(self, context: dict) -> None:
132
+ """Install dependencies or run post-initialization commands."""
133
+ return None
134
+
135
+ def create_skeleton(self, context: dict) -> set[Path]:
136
+ """Create the filesystem skeleton for this action."""
137
+ created_paths: set[Path] = set()
138
+ skeleton = self.skeleton(context)
139
+
140
+ for path, content in skeleton.items():
141
+ full_path = self.project_path / path
142
+
143
+ full_path.parent.mkdir(parents=True, exist_ok=True)
144
+
145
+ if content is None:
146
+ full_path.mkdir(exist_ok=True)
147
+ else:
148
+ if full_path.exists():
149
+ with open(full_path, "a") as f:
150
+ f.write("\n" + content)
151
+ else:
152
+ full_path.write_text(content)
153
+
154
+ created_paths.add(full_path)
155
+
156
+ return created_paths
157
+
158
+ def activate(self, context: dict) -> bool:
159
+ """Return True if this action should be executed given the current context."""
160
+ return True
161
+
162
+
163
+ def cli_option_to_key(cli_option: CLIOption) -> str:
164
+ """Convert a CLI option name to a context dictionary key."""
165
+ return cli_option.name.lstrip("-").replace("-", "_")
166
+
167
+
168
+ def discover_actions_from_package(package_path: str, package_name: str) -> list[type[Action]]:
169
+ """Discover Action subclasses by walking a package directory (pkgutil-based).
170
+
171
+ Args:
172
+ package_path: Filesystem path to the package directory
173
+ package_name: Fully qualified Python package name for import
174
+ """
175
+ actions: list[type[Action]] = []
176
+ for _, module_name, _ in pkgutil.iter_modules([package_path]):
177
+ if module_name in ("base", "manager", "__pycache__"):
178
+ continue
179
+ mod = importlib.import_module(f"{package_name}.{module_name}")
180
+ for attr in dir(mod):
181
+ obj = getattr(mod, attr)
182
+ if isinstance(obj, type) and issubclass(obj, Action) and obj is not Action:
183
+ actions.append(obj)
184
+ return actions
185
+
186
+
187
+ def discover_actions_from_entry_points(group: str = "pyscaf_core.plugins") -> list[type[Action]]:
188
+ """Discover Action subclasses registered via importlib.metadata entry points.
189
+
190
+ Each entry point should reference either:
191
+ - A callable that returns a list of Action subclasses
192
+ - A module containing Action subclasses
193
+ """
194
+ from importlib.metadata import entry_points
195
+
196
+ actions: list[type[Action]] = []
197
+ eps = entry_points().select(group=group)
198
+ for ep in eps:
199
+ obj = ep.load()
200
+ if isinstance(obj, type) and issubclass(obj, Action) and obj is not Action:
201
+ actions.append(obj)
202
+ elif callable(obj):
203
+ result = obj()
204
+ if isinstance(result, list):
205
+ actions.extend(result)
206
+ elif isinstance(result, type) and issubclass(result, Action):
207
+ actions.append(result)
208
+ elif isinstance(obj, type(os)):
209
+ for attr_name in dir(obj):
210
+ attr = getattr(obj, attr_name)
211
+ if isinstance(attr, type) and issubclass(attr, Action) and attr is not Action:
212
+ actions.append(attr)
213
+ return actions
@@ -0,0 +1,176 @@
1
+ """Project action manager module — core engine."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import questionary
8
+
9
+ from pyscaf_core.actions import Action, cli_option_to_key
10
+ from pyscaf_core.preference_chain import best_execution_order
11
+ from pyscaf_core.preference_chain.model import Node
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class ActionManager:
17
+ """Manager for all project actions."""
18
+
19
+ def __init__(
20
+ self,
21
+ project_name: str | Path,
22
+ context: dict[str, Any],
23
+ action_classes: list[type[Action]] | None = None,
24
+ discover: Any = None,
25
+ ):
26
+ self.project_path = Path.cwd() / project_name
27
+ logger.info("Project path: %s", self.project_path)
28
+ self.context = context
29
+ self.actions: list[Action] = []
30
+
31
+ if action_classes is not None:
32
+ self._action_classes = action_classes
33
+ elif discover is not None:
34
+ self._action_classes = discover()
35
+ else:
36
+ from pyscaf_core.actions import discover_actions_from_entry_points
37
+
38
+ self._action_classes = discover_actions_from_entry_points()
39
+
40
+ self._determine_actions()
41
+
42
+ def _determine_actions(self) -> None:
43
+ """Determine which actions to include based on configuration using the preference chain logic."""
44
+ nodes: list[Node] = []
45
+ action_class_by_id: dict[str, type[Action]] = {}
46
+
47
+ for action_cls in self._action_classes:
48
+ action_id = action_cls.__name__.replace("Action", "").lower()
49
+ depends = getattr(action_cls, "depends", set())
50
+ after = getattr(action_cls, "run_preferably_after", None)
51
+
52
+ node = Node(id=action_id, depends=depends, after=after)
53
+ nodes.append(node)
54
+ action_class_by_id[action_id] = action_cls
55
+
56
+ logger.debug("Created %d action nodes", len(nodes))
57
+
58
+ if not nodes:
59
+ self.actions = []
60
+ return
61
+
62
+ order = best_execution_order(nodes)
63
+
64
+ logger.debug("Final action execution order: %s", order)
65
+
66
+ self.actions = [
67
+ action_class_by_id[action_id](self.project_path)
68
+ for action_id in order
69
+ if action_id in action_class_by_id
70
+ ]
71
+
72
+ def run_postfill_hooks(self, context: dict) -> dict:
73
+ """Run all postfill hooks for actions in optimal order."""
74
+ for action in self.actions:
75
+ if action.activate(context):
76
+ for opt in action.cli_options:
77
+ context_key = cli_option_to_key(opt)
78
+ if context.get(context_key) is None:
79
+ continue
80
+ if opt.visible_when and not opt.visible_when(context):
81
+ continue
82
+ if opt.postfill_hook:
83
+ context = opt.postfill_hook(context)
84
+ return context
85
+
86
+ def ask_interactive_questions(self, context: dict) -> dict:
87
+ """Ask all relevant questions for actions in optimal order, updating the context."""
88
+ for action in self.actions:
89
+ if action.activate(context):
90
+ for opt in action.cli_options:
91
+ context_key = cli_option_to_key(opt)
92
+ if context.get(context_key) is not None:
93
+ continue
94
+ if opt.visible_when and not opt.visible_when(context):
95
+ continue
96
+ prompt = opt.prompt or context_key
97
+ if opt.type == "choice":
98
+ default = opt.get_default_value()
99
+ else:
100
+ default = opt.default() if callable(opt.default) else opt.default
101
+ if opt.type == "bool":
102
+ answer = questionary.confirm(prompt, default=bool(default)).ask()
103
+ elif opt.type == "int":
104
+ answer = questionary.text(
105
+ prompt, default=str(default) if default is not None else ""
106
+ ).ask()
107
+ answer = int(answer) if answer is not None and answer != "" else None
108
+ elif opt.type == "choice" and opt.choices:
109
+ choices = opt.get_choice_displays()
110
+ default_display = opt.get_default_display()
111
+
112
+ if opt.multiple:
113
+ answer = questionary.checkbox(
114
+ prompt, choices=choices, default=default_display
115
+ ).ask()
116
+ else:
117
+ answer = questionary.select(
118
+ prompt, choices=choices, default=default_display
119
+ ).ask()
120
+
121
+ if answer:
122
+ if opt.multiple:
123
+ converted_answer = []
124
+ for display in answer:
125
+ for choice in opt.choices:
126
+ if choice.display == display:
127
+ converted_answer.append(choice.key)
128
+ break
129
+ answer = converted_answer
130
+ else:
131
+ for choice in opt.choices:
132
+ if choice.display == answer:
133
+ answer = choice.key
134
+ break
135
+
136
+ else:
137
+ answer = questionary.text(
138
+ prompt, default=default if default is not None else ""
139
+ ).ask()
140
+ context[context_key] = answer
141
+ if opt.postfill_hook:
142
+ context = opt.postfill_hook(context)
143
+ return context
144
+
145
+ def create_project(self) -> None:
146
+ """Create the project structure and initialize it."""
147
+ self.project_path.mkdir(parents=True, exist_ok=True)
148
+
149
+ logger.info("Creating project at: %s", self.project_path)
150
+
151
+ for action in self.actions:
152
+ if not action.activate(self.context):
153
+ logger.info("Skipping %s", action.__class__.__name__)
154
+ continue
155
+ action_name = action.__class__.__name__
156
+ logger.info("Creating skeleton for: %s", action_name)
157
+ action.create_skeleton(self.context)
158
+
159
+ for action in self.actions:
160
+ if not action.activate(self.context):
161
+ continue
162
+ action_name = action.__class__.__name__
163
+ logger.info("Initializing: %s", action_name)
164
+ action.init(self.context)
165
+
166
+ if not self.context.get("no_install", False):
167
+ for action in self.actions:
168
+ if not action.activate(self.context):
169
+ continue
170
+ action_name = action.__class__.__name__
171
+ logger.info("Installing dependencies for: %s", action_name)
172
+ action.install(self.context)
173
+ else:
174
+ logger.info("Skipping installation.")
175
+
176
+ logger.info("Project creation complete!")
@@ -0,0 +1,211 @@
1
+ """CLI framework for pyscaf-core based applications."""
2
+
3
+ import sys
4
+ from typing import Any
5
+
6
+ import click
7
+ from rich.console import Console
8
+
9
+ from pyscaf_core.actions import (
10
+ Action,
11
+ CLIOption,
12
+ cli_option_to_key,
13
+ discover_actions_from_entry_points,
14
+ )
15
+ from pyscaf_core.actions.manager import ActionManager
16
+ from pyscaf_core.preference_chain import best_execution_order
17
+ from pyscaf_core.preference_chain.model import Node
18
+
19
+
20
+ def set_option_default(opt: CLIOption) -> Any:
21
+ """Compute the default value for a CLI option."""
22
+ if opt.type == "choice":
23
+ default_index = opt.default
24
+ if default_index is not None and opt.choices:
25
+ return opt.choices[default_index].key
26
+ return None
27
+ return opt.default() if callable(opt.default) else opt.default
28
+
29
+
30
+ def collect_cli_options(
31
+ action_classes: list[type[Action]] | None = None,
32
+ discover: Any = None,
33
+ ) -> list[CLIOption]:
34
+ """Collect CLI options from all actions in dependency order.
35
+
36
+ Args:
37
+ action_classes: Explicit list of action classes
38
+ discover: Callable returning action classes
39
+ """
40
+ if action_classes is not None:
41
+ classes = action_classes
42
+ elif discover is not None:
43
+ classes = discover()
44
+ else:
45
+ classes = discover_actions_from_entry_points()
46
+
47
+ deps: list[Node] = []
48
+ action_class_by_id: dict[str, type[Action]] = {}
49
+ for action_cls in classes:
50
+ action_id = action_cls.__name__.replace("Action", "").lower()
51
+ depends = getattr(action_cls, "depends", set())
52
+ after = getattr(action_cls, "run_preferably_after", None)
53
+
54
+ if depends and after is None:
55
+ after = next(iter(depends))
56
+
57
+ node = Node(id=action_id, depends=depends, after=after)
58
+ deps.append(node)
59
+ action_class_by_id[action_id] = action_cls
60
+
61
+ if not deps:
62
+ return []
63
+
64
+ order = best_execution_order(deps)
65
+ cli_options: list[CLIOption] = []
66
+ for action_id in order:
67
+ action_cls = action_class_by_id[action_id]
68
+ cli_options.extend(getattr(action_cls, "cli_options", []))
69
+ return cli_options
70
+
71
+
72
+ def fill_default_context(
73
+ context: dict,
74
+ action_classes: list[type[Action]] | None = None,
75
+ discover: Any = None,
76
+ ) -> dict:
77
+ """Fill the context with default values from all actions."""
78
+ if action_classes is not None:
79
+ classes = action_classes
80
+ elif discover is not None:
81
+ classes = discover()
82
+ else:
83
+ classes = discover_actions_from_entry_points()
84
+
85
+ for action_cls in classes:
86
+ if hasattr(action_cls, "cli_options"):
87
+ for opt in action_cls.cli_options:
88
+ name = cli_option_to_key(opt)
89
+ if name not in context or context[name] is None:
90
+ if opt.visible_when and not opt.visible_when(context):
91
+ continue
92
+ context[name] = set_option_default(opt)
93
+
94
+ return context
95
+
96
+
97
+ def add_dynamic_options(
98
+ command: click.Command,
99
+ action_classes: list[type[Action]] | None = None,
100
+ discover: Any = None,
101
+ ) -> click.Command:
102
+ """Add dynamic Click options from all actions to a command."""
103
+ cli_options = collect_cli_options(action_classes=action_classes, discover=discover)
104
+ for opt in reversed(cli_options):
105
+ param_decls = [opt.name]
106
+ click_opts: dict[str, Any] = {}
107
+ if opt.type == "int":
108
+ click_opts["type"] = int
109
+ elif opt.type == "choice" and opt.choices:
110
+ choice_keys = opt.get_choice_keys()
111
+ click_opts["type"] = click.Choice(choice_keys, case_sensitive=False)
112
+ if opt.multiple:
113
+ click_opts["multiple"] = True
114
+ elif opt.type == "str":
115
+ click_opts["type"] = str
116
+ elif opt.type == "bool":
117
+ click_opts["type"] = click.BOOL
118
+ click_opts["default"] = None
119
+ base_name = opt.name.lstrip("-")
120
+ param_decls[0] = f"--{base_name}/--no-{base_name}"
121
+
122
+ if opt.help:
123
+ click_opts["help"] = opt.help
124
+ if opt.required:
125
+ click_opts["required"] = True
126
+ command = click.option(*param_decls, **click_opts)(command)
127
+ return command
128
+
129
+
130
+ def build_cli(
131
+ app_name: str,
132
+ version: str,
133
+ action_classes: list[type[Action]] | None = None,
134
+ discover: Any = None,
135
+ ) -> click.Group:
136
+ """Build a complete Click CLI group for a pyscaf-core based application.
137
+
138
+ Args:
139
+ app_name: Name displayed in CLI help
140
+ version: Version string for --version
141
+ action_classes: Explicit list of action classes (mutually exclusive with discover)
142
+ discover: Callable returning action classes (mutually exclusive with action_classes)
143
+
144
+ Returns:
145
+ A click.Group with an 'init' command wired to ActionManager
146
+ """
147
+ console = Console()
148
+
149
+ def print_version(ctx: click.Context, param: click.Parameter, value: bool) -> None:
150
+ if not value or ctx.resilient_parsing:
151
+ return
152
+ console.print(f"{app_name} version {version}")
153
+ ctx.exit()
154
+
155
+ @click.group()
156
+ @click.version_option(
157
+ version,
158
+ "--version",
159
+ "-V",
160
+ callback=print_version,
161
+ help="Show the version and exit.",
162
+ )
163
+ def cli() -> None:
164
+ pass
165
+
166
+ cli.help = f"{app_name} — project generator with plugin-based action system."
167
+
168
+ @click.command()
169
+ @click.argument("project_name")
170
+ @click.option(
171
+ "--interactive",
172
+ is_flag=True,
173
+ help="Enable interactive mode (asks questions to the user).",
174
+ )
175
+ @click.option("--no-install", is_flag=True, help="Skip installation step.")
176
+ def init(project_name: str, interactive: bool, no_install: bool, **kwargs: Any) -> None:
177
+ """Initialize a new customized project structure."""
178
+ context = dict(kwargs)
179
+ context["project_name"] = project_name
180
+ context["interactive"] = interactive
181
+ context["no_install"] = no_install
182
+
183
+ if not interactive:
184
+ context = fill_default_context(context, action_classes=action_classes, discover=discover)
185
+
186
+ manager = ActionManager(project_name, context, action_classes=action_classes, discover=discover)
187
+ context = manager.run_postfill_hooks(context)
188
+
189
+ if interactive:
190
+ context = manager.ask_interactive_questions(context)
191
+ manager.context = context
192
+ manager.create_project()
193
+
194
+ init = add_dynamic_options(init, action_classes=action_classes, discover=discover)
195
+ cli.add_command(init, "init")
196
+
197
+ return cli
198
+
199
+
200
+ def make_main(cli_group: click.Group) -> Any:
201
+ """Create a main() entry point from a CLI group."""
202
+
203
+ def main() -> None:
204
+ try:
205
+ cli_group()
206
+ except Exception as e:
207
+ console = Console()
208
+ console.print(f"[bold red]Error:[/bold red] {e!s}")
209
+ sys.exit(1)
210
+
211
+ return main