cli-wizard 1.0.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.
- cli_wizard/__init__.py +4 -0
- cli_wizard/cli.py +37 -0
- cli_wizard/commands/__init__.py +4 -0
- cli_wizard/commands/common.py +36 -0
- cli_wizard/commands/config.py +82 -0
- cli_wizard/commands/generate.py +231 -0
- cli_wizard/config/__init__.py +0 -0
- cli_wizard/config/configuration.py +53 -0
- cli_wizard/config/default_config.yaml +7 -0
- cli_wizard/config/schema.py +182 -0
- cli_wizard/constants.py +11 -0
- cli_wizard/generator/__init__.py +22 -0
- cli_wizard/generator/generator.py +241 -0
- cli_wizard/generator/models.py +123 -0
- cli_wizard/generator/parser.py +171 -0
- cli_wizard/templates/README.md.j2 +31 -0
- cli_wizard/templates/cli.py.j2 +85 -0
- cli_wizard/templates/client.py.j2 +134 -0
- cli_wizard/templates/command_group.py.j2 +161 -0
- cli_wizard/templates/commands_config.py.j2 +206 -0
- cli_wizard/templates/commands_init.py.j2 +14 -0
- cli_wizard/templates/constants.py.j2 +67 -0
- cli_wizard/templates/logging.py.j2 +175 -0
- cli_wizard/templates/package_init.py.j2 +8 -0
- cli_wizard/templates/profile.py.j2 +87 -0
- cli_wizard/templates/pyproject.toml.j2 +28 -0
- cli_wizard-1.0.0.dist-info/METADATA +236 -0
- cli_wizard-1.0.0.dist-info/RECORD +32 -0
- cli_wizard-1.0.0.dist-info/WHEEL +5 -0
- cli_wizard-1.0.0.dist-info/entry_points.txt +2 -0
- cli_wizard-1.0.0.dist-info/licenses/LICENSE +21 -0
- cli_wizard-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# Copyright (c) 2026, Giacomo Marciani
|
|
2
|
+
# Licensed under the MIT License
|
|
3
|
+
|
|
4
|
+
"""CLI code generator using Jinja2 templates."""
|
|
5
|
+
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from jinja2 import Environment, PackageLoader
|
|
11
|
+
|
|
12
|
+
from cli_wizard.generator.models import CommandGroup, Operation
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _build_url_path(op: Operation) -> str:
|
|
16
|
+
"""Build URL path with Python variable substitutions."""
|
|
17
|
+
path = op.path
|
|
18
|
+
for param in op.parameters:
|
|
19
|
+
if param.location == "path":
|
|
20
|
+
path = path.replace(f"{{{param.name}}}", f"{{{param.python_name}}}")
|
|
21
|
+
return path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CliGenerator:
|
|
25
|
+
"""Generates Click CLI code from parsed OpenAPI."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self, config: dict[str, Any] | None = None, config_dir: Path | None = None
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Initialize generator with package templates."""
|
|
31
|
+
self.config = config or {}
|
|
32
|
+
self.config_dir = config_dir or Path.cwd()
|
|
33
|
+
self.env = Environment(
|
|
34
|
+
loader=PackageLoader("cli_wizard", "templates"),
|
|
35
|
+
trim_blocks=True,
|
|
36
|
+
lstrip_blocks=True,
|
|
37
|
+
)
|
|
38
|
+
self.env.filters["url_path"] = _build_url_path
|
|
39
|
+
|
|
40
|
+
def generate(
|
|
41
|
+
self,
|
|
42
|
+
groups: dict[str, CommandGroup],
|
|
43
|
+
output_dir: Path,
|
|
44
|
+
cli_name: str,
|
|
45
|
+
package_name: str,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Generate a complete CLI project."""
|
|
48
|
+
self.package_name = package_name
|
|
49
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
|
|
51
|
+
# Create project structure
|
|
52
|
+
src_dir = output_dir / "src" / package_name
|
|
53
|
+
commands_dir = src_dir / "commands"
|
|
54
|
+
resources_dir = src_dir / "resources"
|
|
55
|
+
commands_dir.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
resources_dir.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
|
|
58
|
+
# Copy resources (e.g., CA file, splash file)
|
|
59
|
+
ca_file_name = self._copy_ca_file(resources_dir)
|
|
60
|
+
splash_file_name = self._copy_splash_file(resources_dir)
|
|
61
|
+
|
|
62
|
+
# Compute main_dir with variable substitution
|
|
63
|
+
main_dir = self._compute_main_dir(package_name)
|
|
64
|
+
|
|
65
|
+
# Generate project files
|
|
66
|
+
self._generate_pyproject(output_dir, cli_name, package_name)
|
|
67
|
+
self._generate_readme(output_dir, cli_name)
|
|
68
|
+
self._generate_version(output_dir)
|
|
69
|
+
|
|
70
|
+
# Generate package files
|
|
71
|
+
self._generate_package_init(src_dir, package_name)
|
|
72
|
+
self._generate_cli_main(src_dir, package_name, groups)
|
|
73
|
+
self._generate_client(src_dir)
|
|
74
|
+
self._generate_logging(src_dir)
|
|
75
|
+
self._generate_profile(src_dir)
|
|
76
|
+
self._generate_constants(src_dir, ca_file_name, splash_file_name, main_dir)
|
|
77
|
+
|
|
78
|
+
# Generate commands
|
|
79
|
+
self._generate_commands_init(commands_dir, groups)
|
|
80
|
+
self._generate_config_commands(commands_dir)
|
|
81
|
+
for tag, group in groups.items():
|
|
82
|
+
self._generate_command_group(group, commands_dir)
|
|
83
|
+
|
|
84
|
+
def _generate_pyproject(
|
|
85
|
+
self, output_dir: Path, cli_name: str, package_name: str
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Generate pyproject.toml."""
|
|
88
|
+
template = self.env.get_template("pyproject.toml.j2")
|
|
89
|
+
content = template.render(
|
|
90
|
+
cli_name=cli_name,
|
|
91
|
+
package_name=package_name,
|
|
92
|
+
config=self.config,
|
|
93
|
+
)
|
|
94
|
+
with open(output_dir / "pyproject.toml", "w") as f:
|
|
95
|
+
f.write(content)
|
|
96
|
+
|
|
97
|
+
def _generate_readme(self, output_dir: Path, cli_name: str) -> None:
|
|
98
|
+
"""Generate README.md."""
|
|
99
|
+
template = self.env.get_template("README.md.j2")
|
|
100
|
+
content = template.render(cli_name=cli_name, config=self.config)
|
|
101
|
+
with open(output_dir / "README.md", "w") as f:
|
|
102
|
+
f.write(content)
|
|
103
|
+
|
|
104
|
+
def _generate_version(self, output_dir: Path) -> None:
|
|
105
|
+
"""Generate VERSION file."""
|
|
106
|
+
version = self.config.get("version", "0.1.0")
|
|
107
|
+
with open(output_dir / "VERSION", "w") as f:
|
|
108
|
+
f.write(f"{version}\n")
|
|
109
|
+
|
|
110
|
+
def _generate_package_init(self, src_dir: Path, package_name: str) -> None:
|
|
111
|
+
"""Generate package __init__.py."""
|
|
112
|
+
template = self.env.get_template("package_init.py.j2")
|
|
113
|
+
content = template.render(package_name=package_name)
|
|
114
|
+
with open(src_dir / "__init__.py", "w") as f:
|
|
115
|
+
f.write(content)
|
|
116
|
+
|
|
117
|
+
def _generate_cli_main(
|
|
118
|
+
self, src_dir: Path, package_name: str, groups: dict[str, CommandGroup]
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Generate main CLI entry point."""
|
|
121
|
+
template = self.env.get_template("cli.py.j2")
|
|
122
|
+
content = template.render(
|
|
123
|
+
package_name=package_name,
|
|
124
|
+
groups=groups,
|
|
125
|
+
config=self.config,
|
|
126
|
+
)
|
|
127
|
+
with open(src_dir / "cli.py", "w") as f:
|
|
128
|
+
f.write(content)
|
|
129
|
+
|
|
130
|
+
def _generate_client(self, src_dir: Path) -> None:
|
|
131
|
+
"""Generate API client module."""
|
|
132
|
+
template = self.env.get_template("client.py.j2")
|
|
133
|
+
content = template.render(config=self.config)
|
|
134
|
+
with open(src_dir / "client.py", "w") as f:
|
|
135
|
+
f.write(content)
|
|
136
|
+
|
|
137
|
+
def _generate_logging(self, src_dir: Path) -> None:
|
|
138
|
+
"""Generate logging module."""
|
|
139
|
+
template = self.env.get_template("logging.py.j2")
|
|
140
|
+
content = template.render(config=self.config)
|
|
141
|
+
with open(src_dir / "logging.py", "w") as f:
|
|
142
|
+
f.write(content)
|
|
143
|
+
|
|
144
|
+
def _generate_profile(self, src_dir: Path) -> None:
|
|
145
|
+
"""Generate profile module."""
|
|
146
|
+
template = self.env.get_template("profile.py.j2")
|
|
147
|
+
content = template.render(config=self.config)
|
|
148
|
+
with open(src_dir / "profile.py", "w") as f:
|
|
149
|
+
f.write(content)
|
|
150
|
+
|
|
151
|
+
def _generate_constants(
|
|
152
|
+
self,
|
|
153
|
+
src_dir: Path,
|
|
154
|
+
ca_file_name: str | None,
|
|
155
|
+
splash_file_name: str | None,
|
|
156
|
+
main_dir: str,
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Generate constants module."""
|
|
159
|
+
template = self.env.get_template("constants.py.j2")
|
|
160
|
+
content = template.render(
|
|
161
|
+
config=self.config,
|
|
162
|
+
ca_file_name=ca_file_name,
|
|
163
|
+
splash_file_name=splash_file_name,
|
|
164
|
+
main_dir=main_dir,
|
|
165
|
+
)
|
|
166
|
+
with open(src_dir / "constants.py", "w") as f:
|
|
167
|
+
f.write(content)
|
|
168
|
+
|
|
169
|
+
def _copy_ca_file(self, resources_dir: Path) -> str | None:
|
|
170
|
+
"""Copy CA file to resources directory if specified in config."""
|
|
171
|
+
ca_file = self.config.get("CaFile")
|
|
172
|
+
if not ca_file:
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
# Resolve CA file path relative to config directory
|
|
176
|
+
ca_path = Path(ca_file)
|
|
177
|
+
if not ca_path.is_absolute():
|
|
178
|
+
ca_path = self.config_dir / ca_file
|
|
179
|
+
|
|
180
|
+
if not ca_path.exists():
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
# Copy to resources directory with original filename
|
|
184
|
+
dest_path = resources_dir / ca_path.name
|
|
185
|
+
shutil.copy2(ca_path, dest_path)
|
|
186
|
+
return ca_path.name
|
|
187
|
+
|
|
188
|
+
def _compute_main_dir(self, package_name: str) -> str:
|
|
189
|
+
"""Get main directory path from config.
|
|
190
|
+
|
|
191
|
+
The #[Param] references should already be resolved by config loader.
|
|
192
|
+
${VAR} environment variables are kept as-is for runtime expansion.
|
|
193
|
+
"""
|
|
194
|
+
main_dir = self.config.get("MainDir")
|
|
195
|
+
if main_dir is not None:
|
|
196
|
+
return str(main_dir)
|
|
197
|
+
return f"${{HOME}}/.{package_name}"
|
|
198
|
+
|
|
199
|
+
def _copy_splash_file(self, resources_dir: Path) -> str | None:
|
|
200
|
+
"""Copy splash file to resources directory if specified in config."""
|
|
201
|
+
splash_file = self.config.get("SplashFile")
|
|
202
|
+
if not splash_file:
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
# Resolve splash file path relative to config directory
|
|
206
|
+
splash_path = Path(splash_file)
|
|
207
|
+
if not splash_path.is_absolute():
|
|
208
|
+
splash_path = self.config_dir / splash_file
|
|
209
|
+
|
|
210
|
+
if not splash_path.exists():
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
# Copy to resources directory with original filename
|
|
214
|
+
dest_path = resources_dir / splash_path.name
|
|
215
|
+
shutil.copy2(splash_path, dest_path)
|
|
216
|
+
return splash_path.name
|
|
217
|
+
|
|
218
|
+
def _generate_commands_init(
|
|
219
|
+
self, commands_dir: Path, groups: dict[str, CommandGroup]
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Generate commands __init__.py."""
|
|
222
|
+
template = self.env.get_template("commands_init.py.j2")
|
|
223
|
+
content = template.render(groups=groups)
|
|
224
|
+
with open(commands_dir / "__init__.py", "w") as f:
|
|
225
|
+
f.write(content)
|
|
226
|
+
|
|
227
|
+
def _generate_config_commands(self, commands_dir: Path) -> None:
|
|
228
|
+
"""Generate config commands module."""
|
|
229
|
+
template = self.env.get_template("commands_config.py.j2")
|
|
230
|
+
content = template.render(package_name=self.package_name, config=self.config)
|
|
231
|
+
with open(commands_dir / "config.py", "w") as f:
|
|
232
|
+
f.write(content)
|
|
233
|
+
|
|
234
|
+
def _generate_command_group(self, group: CommandGroup, commands_dir: Path) -> None:
|
|
235
|
+
"""Generate a command group file."""
|
|
236
|
+
template = self.env.get_template("command_group.py.j2")
|
|
237
|
+
content = template.render(
|
|
238
|
+
group=group, config=self.config, package_name=self.package_name
|
|
239
|
+
)
|
|
240
|
+
with open(commands_dir / f"{group.module_name}.py", "w") as f:
|
|
241
|
+
f.write(content)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Copyright (c) 2026, Giacomo Marciani
|
|
2
|
+
# Licensed under the MIT License
|
|
3
|
+
|
|
4
|
+
"""Data models for CLI generation."""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Parameter:
|
|
13
|
+
"""Represents an API parameter."""
|
|
14
|
+
|
|
15
|
+
name: str
|
|
16
|
+
location: str # path, query, header
|
|
17
|
+
param_type: str
|
|
18
|
+
required: bool
|
|
19
|
+
description: str = ""
|
|
20
|
+
default: Any = None
|
|
21
|
+
enum: list[str] = field(default_factory=list)
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def cli_name(self) -> str:
|
|
25
|
+
"""Get CLI-friendly parameter name (kebab-case)."""
|
|
26
|
+
return re.sub(r"([a-z])([A-Z])", r"\1-\2", self.name).lower().replace("_", "-")
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def python_name(self) -> str:
|
|
30
|
+
"""Get Python-friendly parameter name (snake_case)."""
|
|
31
|
+
name = re.sub(r"([a-z])([A-Z])", r"\1_\2", self.name).lower()
|
|
32
|
+
return name.replace("-", "_")
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def click_type(self) -> str:
|
|
36
|
+
"""Get Click type for this parameter."""
|
|
37
|
+
type_map = {
|
|
38
|
+
"string": "str",
|
|
39
|
+
"integer": "int",
|
|
40
|
+
"number": "float",
|
|
41
|
+
"boolean": "bool",
|
|
42
|
+
}
|
|
43
|
+
return type_map.get(self.param_type, "str")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class RequestBodyProperty:
|
|
48
|
+
"""Represents a request body property."""
|
|
49
|
+
|
|
50
|
+
name: str
|
|
51
|
+
prop_type: str
|
|
52
|
+
required: bool
|
|
53
|
+
description: str = ""
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def cli_name(self) -> str:
|
|
57
|
+
"""Get CLI-friendly name (kebab-case)."""
|
|
58
|
+
name = re.sub(r"([a-z])([A-Z])", r"\1-\2", self.name).lower()
|
|
59
|
+
return name.replace("_", "-")
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def python_name(self) -> str:
|
|
63
|
+
"""Get Python-friendly name (snake_case)."""
|
|
64
|
+
name = re.sub(r"([a-z])([A-Z])", r"\1_\2", self.name).lower()
|
|
65
|
+
return name.replace("-", "_")
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def click_type(self) -> str:
|
|
69
|
+
"""Get Click type."""
|
|
70
|
+
type_map = {
|
|
71
|
+
"string": "str",
|
|
72
|
+
"integer": "int",
|
|
73
|
+
"number": "float",
|
|
74
|
+
"boolean": "bool",
|
|
75
|
+
}
|
|
76
|
+
return type_map.get(self.prop_type, "str")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class Operation:
|
|
81
|
+
"""Represents an API operation."""
|
|
82
|
+
|
|
83
|
+
operation_id: str
|
|
84
|
+
method: str
|
|
85
|
+
path: str
|
|
86
|
+
summary: str
|
|
87
|
+
description: str
|
|
88
|
+
tags: list[str]
|
|
89
|
+
parameters: list[Parameter]
|
|
90
|
+
body_properties: list[RequestBodyProperty] = field(default_factory=list)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def _base_operation_id(self) -> str:
|
|
94
|
+
"""Get the base operation ID without module path (e.g., 'server.get_greetings' -> 'get_greetings')."""
|
|
95
|
+
if "." in self.operation_id:
|
|
96
|
+
return self.operation_id.rsplit(".", 1)[-1]
|
|
97
|
+
return self.operation_id
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def command_name(self) -> str:
|
|
101
|
+
"""Get CLI command name from operation ID (kebab-case)."""
|
|
102
|
+
name = re.sub(r"(?<!^)(?=[A-Z])", "-", self._base_operation_id).lower()
|
|
103
|
+
return name.replace("_", "-")
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def function_name(self) -> str:
|
|
107
|
+
"""Get Python function name from operation ID (snake_case)."""
|
|
108
|
+
return self.command_name.replace("-", "_")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class CommandGroup:
|
|
113
|
+
"""Represents a group of commands (from a tag)."""
|
|
114
|
+
|
|
115
|
+
name: str
|
|
116
|
+
cli_name: str
|
|
117
|
+
description: str
|
|
118
|
+
operations: list[Operation] = field(default_factory=list)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def module_name(self) -> str:
|
|
122
|
+
"""Get Python module name."""
|
|
123
|
+
return self.cli_name.replace("-", "_")
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Copyright (c) 2026, Giacomo Marciani
|
|
2
|
+
# Licensed under the MIT License
|
|
3
|
+
|
|
4
|
+
"""OpenAPI specification parser."""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from cli_wizard.generator.models import (
|
|
13
|
+
CommandGroup,
|
|
14
|
+
Operation,
|
|
15
|
+
Parameter,
|
|
16
|
+
RequestBodyProperty,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OpenApiParser:
|
|
21
|
+
"""Parses OpenAPI specification."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, spec_path: str) -> None:
|
|
24
|
+
"""Initialize parser with spec path."""
|
|
25
|
+
self.spec_path = Path(spec_path)
|
|
26
|
+
self.spec = self._load_spec()
|
|
27
|
+
|
|
28
|
+
def _load_spec(self) -> dict[str, Any]:
|
|
29
|
+
"""Load OpenAPI spec from file (JSON or YAML)."""
|
|
30
|
+
with open(self.spec_path) as f:
|
|
31
|
+
content = f.read()
|
|
32
|
+
|
|
33
|
+
# Try JSON first, then YAML
|
|
34
|
+
if self.spec_path.suffix.lower() == ".json":
|
|
35
|
+
result = json.loads(content)
|
|
36
|
+
return dict(result) if isinstance(result, dict) else {}
|
|
37
|
+
elif self.spec_path.suffix.lower() in (".yaml", ".yml"):
|
|
38
|
+
result = yaml.safe_load(content)
|
|
39
|
+
return dict(result) if isinstance(result, dict) else {}
|
|
40
|
+
else:
|
|
41
|
+
# Try to detect format
|
|
42
|
+
try:
|
|
43
|
+
result = json.loads(content)
|
|
44
|
+
return dict(result) if isinstance(result, dict) else {}
|
|
45
|
+
except json.JSONDecodeError:
|
|
46
|
+
result = yaml.safe_load(content)
|
|
47
|
+
return dict(result) if isinstance(result, dict) else {}
|
|
48
|
+
|
|
49
|
+
def parse(
|
|
50
|
+
self,
|
|
51
|
+
exclude_tags: list[str] | None = None,
|
|
52
|
+
include_tags: list[str] | None = None,
|
|
53
|
+
tag_mapping: dict[str, str] | None = None,
|
|
54
|
+
) -> dict[str, CommandGroup]:
|
|
55
|
+
"""Parse the OpenAPI spec into command groups."""
|
|
56
|
+
exclude_tags = exclude_tags or []
|
|
57
|
+
include_tags = include_tags or []
|
|
58
|
+
tag_mapping = tag_mapping or {}
|
|
59
|
+
|
|
60
|
+
groups: dict[str, CommandGroup] = {}
|
|
61
|
+
|
|
62
|
+
for path, path_item in self.spec.get("paths", {}).items():
|
|
63
|
+
for method, operation in path_item.items():
|
|
64
|
+
if method not in ("get", "post", "put", "patch", "delete"):
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
tags = operation.get("tags", ["default"])
|
|
68
|
+
for tag in tags:
|
|
69
|
+
if tag in exclude_tags:
|
|
70
|
+
continue
|
|
71
|
+
if include_tags and tag not in include_tags:
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
if tag not in groups:
|
|
75
|
+
cli_name = tag_mapping.get(tag, self._tag_to_cli_name(tag))
|
|
76
|
+
groups[tag] = CommandGroup(
|
|
77
|
+
name=tag,
|
|
78
|
+
cli_name=cli_name,
|
|
79
|
+
description=self._get_tag_description(tag),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
op = self._parse_operation(path, method, operation)
|
|
83
|
+
groups[tag].operations.append(op)
|
|
84
|
+
|
|
85
|
+
return groups
|
|
86
|
+
|
|
87
|
+
def _tag_to_cli_name(self, tag: str) -> str:
|
|
88
|
+
"""Convert tag name to CLI-friendly name."""
|
|
89
|
+
return tag.lower().replace(" ", "-")
|
|
90
|
+
|
|
91
|
+
def _get_tag_description(self, tag: str) -> str:
|
|
92
|
+
"""Get tag description from spec."""
|
|
93
|
+
for tag_def in self.spec.get("tags", []):
|
|
94
|
+
if tag_def.get("name") == tag:
|
|
95
|
+
desc = tag_def.get("description", f"{tag} commands")
|
|
96
|
+
return str(desc)
|
|
97
|
+
return f"{tag} commands"
|
|
98
|
+
|
|
99
|
+
def _parse_operation(
|
|
100
|
+
self, path: str, method: str, operation: dict[str, Any]
|
|
101
|
+
) -> Operation:
|
|
102
|
+
"""Parse a single operation."""
|
|
103
|
+
parameters = []
|
|
104
|
+
for param in operation.get("parameters", []):
|
|
105
|
+
parameters.append(self._parse_parameter(param))
|
|
106
|
+
|
|
107
|
+
body_properties: list[RequestBodyProperty] = []
|
|
108
|
+
if "requestBody" in operation:
|
|
109
|
+
body_properties = self._parse_request_body(operation["requestBody"])
|
|
110
|
+
|
|
111
|
+
return Operation(
|
|
112
|
+
operation_id=operation.get("operationId", f"{method}_{path}"),
|
|
113
|
+
method=method.upper(),
|
|
114
|
+
path=path,
|
|
115
|
+
summary=operation.get("summary", ""),
|
|
116
|
+
description=operation.get("description", ""),
|
|
117
|
+
tags=operation.get("tags", []),
|
|
118
|
+
parameters=parameters,
|
|
119
|
+
body_properties=body_properties,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def _parse_parameter(self, param: dict[str, Any]) -> Parameter:
|
|
123
|
+
"""Parse a parameter definition."""
|
|
124
|
+
schema = param.get("schema", {})
|
|
125
|
+
return Parameter(
|
|
126
|
+
name=param["name"],
|
|
127
|
+
location=param["in"],
|
|
128
|
+
param_type=schema.get("type", "string"),
|
|
129
|
+
required=param.get("required", False),
|
|
130
|
+
description=param.get("description", ""),
|
|
131
|
+
default=schema.get("default"),
|
|
132
|
+
enum=schema.get("enum", []),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def _parse_request_body(self, body: dict[str, Any]) -> list[RequestBodyProperty]:
|
|
136
|
+
"""Parse request body definition into properties."""
|
|
137
|
+
content = body.get("content", {})
|
|
138
|
+
json_content = content.get("application/json", {})
|
|
139
|
+
schema = json_content.get("schema", {})
|
|
140
|
+
|
|
141
|
+
# Handle $ref
|
|
142
|
+
if "$ref" in schema:
|
|
143
|
+
schema = self._resolve_ref(schema["$ref"])
|
|
144
|
+
|
|
145
|
+
if not schema:
|
|
146
|
+
return []
|
|
147
|
+
|
|
148
|
+
required_props = schema.get("required", [])
|
|
149
|
+
properties = []
|
|
150
|
+
|
|
151
|
+
for prop_name, prop_schema in schema.get("properties", {}).items():
|
|
152
|
+
properties.append(
|
|
153
|
+
RequestBodyProperty(
|
|
154
|
+
name=prop_name,
|
|
155
|
+
prop_type=prop_schema.get("type", "string"),
|
|
156
|
+
required=prop_name in required_props,
|
|
157
|
+
description=prop_schema.get("description", ""),
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return properties
|
|
162
|
+
|
|
163
|
+
def _resolve_ref(self, ref: str) -> dict[str, Any]:
|
|
164
|
+
"""Resolve a $ref to its schema."""
|
|
165
|
+
parts = ref.split("/")
|
|
166
|
+
if len(parts) != 4 or parts[1] != "components":
|
|
167
|
+
return {}
|
|
168
|
+
|
|
169
|
+
schema_name = parts[3]
|
|
170
|
+
result = self.spec.get("components", {}).get("schemas", {}).get(schema_name, {})
|
|
171
|
+
return dict(result) if isinstance(result, dict) else {}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# {{ cli_name }}
|
|
2
|
+
|
|
3
|
+
CLI generated by [cli-wizard](https://github.com/gmarciani/cli-wizard) from OpenAPI specification.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install -e .
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
{{ cli_name }} --help
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
{% if config.api and config.api.default_base_url %}
|
|
18
|
+
## Configuration
|
|
19
|
+
|
|
20
|
+
Set the API base URL:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
export API_BASE_URL="{{ config.api.default_base_url }}"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Set the API key (if required):
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
export API_KEY="your-api-key"
|
|
30
|
+
```
|
|
31
|
+
{% endif %}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# AUTO-GENERATED FILE - DO NOT EDIT
|
|
2
|
+
# Generated from OpenAPI specification by cli-wizard
|
|
3
|
+
|
|
4
|
+
"""Main CLI entry point."""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from {{ package_name }}.constants import __version__, DEFAULT_BASE_URL, DEFAULT_CA_FILE, SPLASH_ENABLED, SPLASH_FILE, SPLASH_COLOR
|
|
13
|
+
from {{ package_name }}.logging import set_debug
|
|
14
|
+
from {{ package_name }}.profile import load_profile
|
|
15
|
+
from {{ package_name }}.commands.config import config
|
|
16
|
+
{% for tag, group in groups.items() %}
|
|
17
|
+
from {{ package_name }}.commands.{{ group.module_name }} import {{ group.module_name }}
|
|
18
|
+
{% endfor %}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
|
|
22
|
+
"""Convert hex color to RGB tuple."""
|
|
23
|
+
hex_color = hex_color.lstrip("#")
|
|
24
|
+
return tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _show_splash() -> None:
|
|
28
|
+
"""Display splash screen if enabled."""
|
|
29
|
+
if SPLASH_ENABLED and SPLASH_FILE and SPLASH_FILE.exists():
|
|
30
|
+
splash_text = SPLASH_FILE.read_text()
|
|
31
|
+
if SPLASH_COLOR:
|
|
32
|
+
rgb = _hex_to_rgb(SPLASH_COLOR)
|
|
33
|
+
click.secho(splash_text, fg=rgb)
|
|
34
|
+
else:
|
|
35
|
+
click.echo(splash_text)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Show splash on startup (before Click processes --help or --version)
|
|
39
|
+
_show_splash()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@click.group(invoke_without_command=True)
|
|
43
|
+
@click.version_option(version=__version__)
|
|
44
|
+
@click.option("--profile", "-p", default="default", help="Profile name to use")
|
|
45
|
+
@click.option("--debug", "-d", is_flag=True, help="Enable debug output")
|
|
46
|
+
@click.option("--base-url", "-u", envvar="API_BASE_URL", help="API base URL")
|
|
47
|
+
@click.option("--no-verify-ssl", is_flag=True, default=False, help="Disable SSL certificate verification")
|
|
48
|
+
@click.option("--ca-file", type=click.Path(exists=True, dir_okay=False, path_type=Path), help="CA certificate file for SSL verification")
|
|
49
|
+
@click.pass_context
|
|
50
|
+
def main(
|
|
51
|
+
ctx: click.Context,
|
|
52
|
+
profile: str,
|
|
53
|
+
debug: bool,
|
|
54
|
+
base_url: str | None,
|
|
55
|
+
no_verify_ssl: bool,
|
|
56
|
+
ca_file: Path | None,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""{{ config.get('Description', 'Generated CLI') }}"""
|
|
59
|
+
ctx.ensure_object(dict)
|
|
60
|
+
|
|
61
|
+
# Enable debug logging if --debug flag is set
|
|
62
|
+
set_debug(debug)
|
|
63
|
+
|
|
64
|
+
# Load profile
|
|
65
|
+
load_profile(profile)
|
|
66
|
+
|
|
67
|
+
ctx.obj["profile"] = profile
|
|
68
|
+
ctx.obj["debug"] = debug
|
|
69
|
+
ctx.obj["base_url"] = base_url
|
|
70
|
+
ctx.obj["no_verify_ssl"] = no_verify_ssl
|
|
71
|
+
ctx.obj["ca_file"] = ca_file
|
|
72
|
+
|
|
73
|
+
# Show help when no subcommand is invoked
|
|
74
|
+
if ctx.invoked_subcommand is None:
|
|
75
|
+
click.echo(ctx.get_help())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
main.add_command(config)
|
|
79
|
+
{% for tag, group in groups.items() %}
|
|
80
|
+
main.add_command({{ group.module_name }})
|
|
81
|
+
{% endfor %}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
main()
|