mcp-souschef 3.0.0__py3-none-any.whl → 3.5.1__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.
- {mcp_souschef-3.0.0.dist-info → mcp_souschef-3.5.1.dist-info}/METADATA +241 -409
- mcp_souschef-3.5.1.dist-info/RECORD +52 -0
- {mcp_souschef-3.0.0.dist-info → mcp_souschef-3.5.1.dist-info}/WHEEL +1 -1
- souschef/__init__.py +2 -10
- souschef/assessment.py +417 -206
- souschef/ci/common.py +1 -1
- souschef/cli.py +302 -19
- souschef/converters/playbook.py +530 -202
- souschef/converters/template.py +122 -5
- souschef/core/__init__.py +6 -1
- souschef/core/ai_schemas.py +81 -0
- souschef/core/http_client.py +394 -0
- souschef/core/logging.py +344 -0
- souschef/core/metrics.py +73 -6
- souschef/core/path_utils.py +233 -19
- souschef/core/url_validation.py +230 -0
- souschef/deployment.py +10 -3
- souschef/generators/__init__.py +13 -0
- souschef/generators/repo.py +695 -0
- souschef/parsers/attributes.py +1 -1
- souschef/parsers/habitat.py +1 -1
- souschef/parsers/inspec.py +25 -2
- souschef/parsers/metadata.py +5 -3
- souschef/parsers/recipe.py +1 -1
- souschef/parsers/resource.py +1 -1
- souschef/parsers/template.py +1 -1
- souschef/server.py +556 -188
- souschef/ui/app.py +44 -36
- souschef/ui/pages/ai_settings.py +151 -30
- souschef/ui/pages/chef_server_settings.py +300 -0
- souschef/ui/pages/cookbook_analysis.py +903 -173
- mcp_souschef-3.0.0.dist-info/RECORD +0 -46
- souschef/converters/cookbook_specific.py.backup +0 -109
- {mcp_souschef-3.0.0.dist-info → mcp_souschef-3.5.1.dist-info}/entry_points.txt +0 -0
- {mcp_souschef-3.0.0.dist-info → mcp_souschef-3.5.1.dist-info}/licenses/LICENSE +0 -0
souschef/ci/common.py
CHANGED
|
@@ -54,7 +54,7 @@ def _parse_kitchen_configuration(kitchen_file: Path) -> tuple[list[str], list[st
|
|
|
54
54
|
kitchen_platforms: list[str] = []
|
|
55
55
|
|
|
56
56
|
try:
|
|
57
|
-
with kitchen_file.open() as file_handle:
|
|
57
|
+
with kitchen_file.open() as file_handle: # nosonar
|
|
58
58
|
kitchen_config = yaml.safe_load(file_handle)
|
|
59
59
|
if not kitchen_config:
|
|
60
60
|
return kitchen_suites, kitchen_platforms
|
souschef/cli.py
CHANGED
|
@@ -7,12 +7,13 @@ Provides easy access to Chef cookbook parsing and conversion tools.
|
|
|
7
7
|
import json
|
|
8
8
|
import sys
|
|
9
9
|
from pathlib import Path
|
|
10
|
-
from typing import NoReturn
|
|
10
|
+
from typing import NoReturn, TypedDict
|
|
11
11
|
|
|
12
12
|
import click
|
|
13
13
|
|
|
14
14
|
from souschef import __version__
|
|
15
15
|
from souschef.converters.playbook import generate_playbook_from_recipe
|
|
16
|
+
from souschef.core.logging import configure_logging
|
|
16
17
|
from souschef.core.path_utils import _normalize_path
|
|
17
18
|
from souschef.profiling import (
|
|
18
19
|
generate_cookbook_performance_report,
|
|
@@ -36,11 +37,25 @@ from souschef.server import (
|
|
|
36
37
|
read_file,
|
|
37
38
|
)
|
|
38
39
|
|
|
40
|
+
|
|
41
|
+
class ConversionResults(TypedDict):
|
|
42
|
+
"""Type definition for cookbook conversion results."""
|
|
43
|
+
|
|
44
|
+
cookbook_name: str
|
|
45
|
+
recipes: dict[str, str]
|
|
46
|
+
templates: dict[str, str]
|
|
47
|
+
attributes: dict[str, str]
|
|
48
|
+
metadata: dict[str, str] | str
|
|
49
|
+
|
|
50
|
+
|
|
39
51
|
# CI/CD job description constants
|
|
40
52
|
CI_JOB_LINT = " • Lint (cookstyle/foodcritic)"
|
|
41
53
|
CI_JOB_UNIT_TESTS = " • Unit Tests (ChefSpec)"
|
|
42
54
|
CI_JOB_INTEGRATION_TESTS = " • Integration Tests (Test Kitchen)"
|
|
43
55
|
|
|
56
|
+
# File name constants
|
|
57
|
+
METADATA_FILENAME = "metadata.rb"
|
|
58
|
+
|
|
44
59
|
|
|
45
60
|
def _resolve_output_path(output: str | None, default_path: Path) -> Path:
|
|
46
61
|
"""Normalise and validate output paths for generated files."""
|
|
@@ -54,6 +69,33 @@ def _resolve_output_path(output: str | None, default_path: Path) -> Path:
|
|
|
54
69
|
return resolved_path
|
|
55
70
|
|
|
56
71
|
|
|
72
|
+
def _safe_write_file(content: str, output: str | None, default_path: Path) -> Path:
|
|
73
|
+
"""
|
|
74
|
+
Safely write content to a validated file path.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
content: Content to write to file.
|
|
78
|
+
output: Optional user-specified output path.
|
|
79
|
+
default_path: Default path if output not specified.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The path where content was written.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
click.Abort: If path validation or write fails.
|
|
86
|
+
|
|
87
|
+
"""
|
|
88
|
+
validated_path = _resolve_output_path(output, default_path)
|
|
89
|
+
try:
|
|
90
|
+
# Separate validation from write to satisfy SonarQube path construction rules
|
|
91
|
+
with validated_path.open("w", encoding="utf-8") as f:
|
|
92
|
+
f.write(content)
|
|
93
|
+
except OSError as e:
|
|
94
|
+
click.echo(f"Error writing file: {e}", err=True)
|
|
95
|
+
raise click.Abort() from e
|
|
96
|
+
return validated_path
|
|
97
|
+
|
|
98
|
+
|
|
57
99
|
@click.group()
|
|
58
100
|
@click.version_option(version=__version__, prog_name="souschef")
|
|
59
101
|
def cli() -> None:
|
|
@@ -308,7 +350,7 @@ def cookbook(cookbook_path: str, output: str | None, dry_run: bool) -> None:
|
|
|
308
350
|
click.echo("=" * 50)
|
|
309
351
|
|
|
310
352
|
# Parse metadata
|
|
311
|
-
metadata_file = cookbook_dir /
|
|
353
|
+
metadata_file = cookbook_dir / METADATA_FILENAME
|
|
312
354
|
if metadata_file.exists():
|
|
313
355
|
click.echo("\n📋 Metadata:")
|
|
314
356
|
click.echo("-" * 50)
|
|
@@ -345,9 +387,124 @@ def cookbook(cookbook_path: str, output: str | None, dry_run: bool) -> None:
|
|
|
345
387
|
for template_file in templates_dir.glob("*.erb"):
|
|
346
388
|
_display_template_summary(template_file)
|
|
347
389
|
|
|
390
|
+
# Convert and save if output directory specified
|
|
348
391
|
if output and not dry_run:
|
|
392
|
+
_save_cookbook_conversion(cookbook_dir, output)
|
|
393
|
+
elif output and dry_run:
|
|
349
394
|
click.echo(f"\n💾 Would save results to: {output}")
|
|
350
|
-
click.echo("(
|
|
395
|
+
click.echo("(Dry run - no files will be written)")
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _save_cookbook_conversion(cookbook_dir: Path, output_path: str) -> None:
|
|
399
|
+
"""
|
|
400
|
+
Convert and save cookbook to Ansible format.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
cookbook_dir: Path to Chef cookbook directory
|
|
404
|
+
output_path: Path to output directory for Ansible files
|
|
405
|
+
|
|
406
|
+
"""
|
|
407
|
+
output_dir = Path(output_path)
|
|
408
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
409
|
+
|
|
410
|
+
click.echo(f"\n💾 Saving conversion to: {output_dir}")
|
|
411
|
+
click.echo("=" * 50)
|
|
412
|
+
|
|
413
|
+
results: ConversionResults = {
|
|
414
|
+
"cookbook_name": cookbook_dir.name,
|
|
415
|
+
"recipes": {},
|
|
416
|
+
"templates": {},
|
|
417
|
+
"attributes": {},
|
|
418
|
+
"metadata": {},
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
# Convert metadata
|
|
422
|
+
metadata_file = cookbook_dir / METADATA_FILENAME
|
|
423
|
+
if metadata_file.exists():
|
|
424
|
+
click.echo("Converting metadata...")
|
|
425
|
+
metadata_result = read_cookbook_metadata(str(metadata_file))
|
|
426
|
+
results["metadata"] = metadata_result
|
|
427
|
+
|
|
428
|
+
# Save metadata as README
|
|
429
|
+
readme_path = output_dir / "README.md"
|
|
430
|
+
with readme_path.open("w") as f:
|
|
431
|
+
f.write(f"# {cookbook_dir.name} - Converted from Chef\n\n")
|
|
432
|
+
f.write("## Metadata\n\n")
|
|
433
|
+
f.write(metadata_result)
|
|
434
|
+
click.echo(f" ✓ Saved metadata to {readme_path}")
|
|
435
|
+
|
|
436
|
+
# Convert recipes to playbooks
|
|
437
|
+
recipes_dir = cookbook_dir / "recipes"
|
|
438
|
+
playbooks_dir = output_dir / "playbooks"
|
|
439
|
+
if recipes_dir.exists():
|
|
440
|
+
playbooks_dir.mkdir(parents=True, exist_ok=True)
|
|
441
|
+
click.echo("\nConverting recipes to playbooks...")
|
|
442
|
+
|
|
443
|
+
for recipe_file in recipes_dir.glob("*.rb"):
|
|
444
|
+
playbook_name = recipe_file.stem
|
|
445
|
+
playbook_content = generate_playbook_from_recipe(str(recipe_file))
|
|
446
|
+
|
|
447
|
+
playbook_path = playbooks_dir / f"{playbook_name}.yml"
|
|
448
|
+
with playbook_path.open("w") as f:
|
|
449
|
+
f.write(playbook_content)
|
|
450
|
+
|
|
451
|
+
results["recipes"][playbook_name] = str(playbook_path)
|
|
452
|
+
click.echo(f" ✓ Converted {recipe_file.name} → {playbook_path}")
|
|
453
|
+
|
|
454
|
+
# Convert templates
|
|
455
|
+
templates_dir = cookbook_dir / "templates" / "default"
|
|
456
|
+
output_templates_dir = output_dir / "templates"
|
|
457
|
+
if templates_dir.exists():
|
|
458
|
+
from souschef.converters.template import convert_template_file
|
|
459
|
+
|
|
460
|
+
output_templates_dir.mkdir(parents=True, exist_ok=True)
|
|
461
|
+
click.echo("\nConverting ERB templates to Jinja2...")
|
|
462
|
+
|
|
463
|
+
for template_file in templates_dir.glob("*.erb"):
|
|
464
|
+
template_result = convert_template_file(str(template_file))
|
|
465
|
+
|
|
466
|
+
if template_result.get("success"):
|
|
467
|
+
jinja_name = template_file.stem + ".j2"
|
|
468
|
+
jinja_path = output_templates_dir / jinja_name
|
|
469
|
+
|
|
470
|
+
with jinja_path.open("w") as f:
|
|
471
|
+
f.write(template_result.get("jinja2_template", ""))
|
|
472
|
+
|
|
473
|
+
results["templates"][template_file.name] = str(jinja_path)
|
|
474
|
+
click.echo(f" ✓ Converted {template_file.name} → {jinja_path}")
|
|
475
|
+
else:
|
|
476
|
+
click.echo(f" ✗ Failed to convert {template_file.name}")
|
|
477
|
+
|
|
478
|
+
# Parse and save attributes
|
|
479
|
+
attributes_dir = cookbook_dir / "attributes"
|
|
480
|
+
if attributes_dir.exists():
|
|
481
|
+
vars_dir = output_dir / "vars"
|
|
482
|
+
vars_dir.mkdir(parents=True, exist_ok=True)
|
|
483
|
+
click.echo("\nExtracting attributes...")
|
|
484
|
+
|
|
485
|
+
for attr_file in attributes_dir.glob("*.rb"):
|
|
486
|
+
attr_result = parse_attributes(str(attr_file))
|
|
487
|
+
|
|
488
|
+
# Save as YAML vars file
|
|
489
|
+
vars_name = attr_file.stem + ".yml"
|
|
490
|
+
vars_path = vars_dir / vars_name
|
|
491
|
+
|
|
492
|
+
with vars_path.open("w") as f:
|
|
493
|
+
f.write("# Converted from Chef attributes\n")
|
|
494
|
+
f.write(f"# Source: {attr_file.name}\n\n")
|
|
495
|
+
f.write(attr_result)
|
|
496
|
+
|
|
497
|
+
results["attributes"][attr_file.name] = str(vars_path)
|
|
498
|
+
click.echo(f" ✓ Extracted {attr_file.name} → {vars_path}")
|
|
499
|
+
|
|
500
|
+
# Save conversion summary
|
|
501
|
+
summary_path = output_dir / "conversion_summary.json"
|
|
502
|
+
with summary_path.open("w") as f:
|
|
503
|
+
json.dump(results, f, indent=2)
|
|
504
|
+
|
|
505
|
+
click.echo("\n✅ Conversion complete!")
|
|
506
|
+
click.echo(f"📁 Output directory: {output_dir}")
|
|
507
|
+
click.echo(f"📄 Summary: {summary_path}")
|
|
351
508
|
|
|
352
509
|
|
|
353
510
|
@cli.command()
|
|
@@ -455,13 +612,13 @@ def generate_jenkinsfile(
|
|
|
455
612
|
)
|
|
456
613
|
|
|
457
614
|
# Determine output path
|
|
458
|
-
|
|
459
|
-
output, default_path=Path.cwd() / "Jenkinsfile"
|
|
460
|
-
)
|
|
615
|
+
_resolve_output_path(output, default_path=Path.cwd() / "Jenkinsfile")
|
|
461
616
|
|
|
462
|
-
# Write Jenkinsfile
|
|
463
|
-
|
|
464
|
-
|
|
617
|
+
# Write Jenkinsfile using safe write helper
|
|
618
|
+
written_path = _safe_write_file(
|
|
619
|
+
result, output, default_path=Path.cwd() / "Jenkinsfile"
|
|
620
|
+
)
|
|
621
|
+
click.echo(f"✓ Generated {pipeline_type} Jenkinsfile: {written_path}")
|
|
465
622
|
|
|
466
623
|
# Show summary
|
|
467
624
|
click.echo("\nGenerated Pipeline Stages:")
|
|
@@ -534,14 +691,11 @@ def generate_gitlab_ci(
|
|
|
534
691
|
enable_artifacts="yes" if artifacts else "no",
|
|
535
692
|
)
|
|
536
693
|
|
|
537
|
-
#
|
|
538
|
-
|
|
539
|
-
output, default_path=Path.cwd() / ".gitlab-ci.yml"
|
|
694
|
+
# Write GitLab CI config using safe write helper
|
|
695
|
+
written_path = _safe_write_file(
|
|
696
|
+
result, output, default_path=Path.cwd() / ".gitlab-ci.yml"
|
|
540
697
|
)
|
|
541
|
-
|
|
542
|
-
# Write GitLab CI config
|
|
543
|
-
output_path.write_text(result)
|
|
544
|
-
click.echo(f"✓ Generated GitLab CI configuration: {output_path}")
|
|
698
|
+
click.echo(f"✓ Generated GitLab CI configuration: {written_path}")
|
|
545
699
|
|
|
546
700
|
# Show summary
|
|
547
701
|
click.echo("\nGenerated CI Jobs:")
|
|
@@ -1195,10 +1349,9 @@ def ui(port: int) -> None:
|
|
|
1195
1349
|
Opens a web-based interface for interactive Chef to Ansible migration planning,
|
|
1196
1350
|
cookbook analysis, and visualization.
|
|
1197
1351
|
"""
|
|
1198
|
-
|
|
1199
|
-
import subprocess
|
|
1200
|
-
import sys
|
|
1352
|
+
import subprocess
|
|
1201
1353
|
|
|
1354
|
+
try:
|
|
1202
1355
|
# Launch Streamlit app
|
|
1203
1356
|
cmd = [
|
|
1204
1357
|
sys.executable,
|
|
@@ -1224,8 +1377,138 @@ def ui(port: int) -> None:
|
|
|
1224
1377
|
sys.exit(1)
|
|
1225
1378
|
|
|
1226
1379
|
|
|
1380
|
+
@cli.command("validate-chef-server")
|
|
1381
|
+
@click.option("--server-url", prompt="Chef Server URL", help="Chef Server base URL")
|
|
1382
|
+
@click.option("--node-name", default="admin", help="Chef node name for authentication")
|
|
1383
|
+
def validate_chef_server(server_url: str, node_name: str) -> None:
|
|
1384
|
+
"""
|
|
1385
|
+
Validate Chef Server connectivity and configuration.
|
|
1386
|
+
|
|
1387
|
+
Tests the connection to the Chef Server REST API to ensure it's
|
|
1388
|
+
reachable and properly configured.
|
|
1389
|
+
"""
|
|
1390
|
+
click.echo("🔍 Validating Chef Server connection...")
|
|
1391
|
+
from souschef.ui.pages.chef_server_settings import _validate_chef_server_connection
|
|
1392
|
+
|
|
1393
|
+
success, message = _validate_chef_server_connection(server_url, node_name)
|
|
1394
|
+
|
|
1395
|
+
if success:
|
|
1396
|
+
click.echo(f"✅ {message}")
|
|
1397
|
+
else:
|
|
1398
|
+
click.echo(f"❌ {message}", err=True)
|
|
1399
|
+
sys.exit(1)
|
|
1400
|
+
|
|
1401
|
+
|
|
1402
|
+
def _display_node_text(node: dict) -> None:
|
|
1403
|
+
"""Display a single node's information in text format."""
|
|
1404
|
+
click.echo(f"\n 📍 {node.get('name', 'unknown')}")
|
|
1405
|
+
click.echo(f" Environment: {node.get('environment', '_default')}")
|
|
1406
|
+
click.echo(f" Platform: {node.get('platform', 'unknown')}")
|
|
1407
|
+
if node.get("ipaddress"):
|
|
1408
|
+
click.echo(f" IP: {node['ipaddress']}")
|
|
1409
|
+
if node.get("fqdn"):
|
|
1410
|
+
click.echo(f" FQDN: {node['fqdn']}")
|
|
1411
|
+
if node.get("roles"):
|
|
1412
|
+
click.echo(f" Roles: {', '.join(node['roles'])}")
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
def _output_chef_nodes(nodes: list, output_json: bool) -> None:
|
|
1416
|
+
"""Output nodes in requested format."""
|
|
1417
|
+
if output_json:
|
|
1418
|
+
click.echo(json.dumps(nodes, indent=2))
|
|
1419
|
+
else:
|
|
1420
|
+
for node in nodes:
|
|
1421
|
+
_display_node_text(node)
|
|
1422
|
+
|
|
1423
|
+
|
|
1424
|
+
@cli.command("query-chef-nodes")
|
|
1425
|
+
@click.option("--search-query", default="*:*", help="Chef search query for nodes")
|
|
1426
|
+
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
|
|
1427
|
+
def query_chef_nodes(search_query: str, output_json: bool) -> None:
|
|
1428
|
+
"""
|
|
1429
|
+
Query Chef Server for nodes matching search criteria.
|
|
1430
|
+
|
|
1431
|
+
Retrieves nodes from Chef Server that match the provided search query,
|
|
1432
|
+
extracting role assignments, environment, platform, and IP address
|
|
1433
|
+
information for dynamic inventory generation.
|
|
1434
|
+
"""
|
|
1435
|
+
import os
|
|
1436
|
+
|
|
1437
|
+
if not os.environ.get("CHEF_SERVER_URL"):
|
|
1438
|
+
click.echo("❌ CHEF_SERVER_URL not set", err=True)
|
|
1439
|
+
sys.exit(1)
|
|
1440
|
+
|
|
1441
|
+
click.echo(f"🔎 Querying Chef Server for nodes matching: {search_query}")
|
|
1442
|
+
|
|
1443
|
+
from souschef.converters.playbook import get_chef_nodes
|
|
1444
|
+
|
|
1445
|
+
try:
|
|
1446
|
+
nodes = get_chef_nodes(search_query)
|
|
1447
|
+
|
|
1448
|
+
if not nodes:
|
|
1449
|
+
click.echo("ℹ️ No nodes found matching the search query")
|
|
1450
|
+
return
|
|
1451
|
+
|
|
1452
|
+
click.echo(f"Found {len(nodes)} nodes:")
|
|
1453
|
+
_output_chef_nodes(nodes, output_json)
|
|
1454
|
+
except Exception as e:
|
|
1455
|
+
click.echo(f"❌ Error querying Chef Server: {e}", err=True)
|
|
1456
|
+
sys.exit(1)
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
@cli.command("convert-template-ai")
|
|
1460
|
+
@click.argument("erb_path", type=click.Path(exists=True))
|
|
1461
|
+
@click.option("--ai/--no-ai", default=True, help="Use AI enhancement")
|
|
1462
|
+
@click.option("--output", type=click.Path(), help="Output path for template")
|
|
1463
|
+
def convert_template_ai(erb_path: str, ai: bool, output: str | None) -> None:
|
|
1464
|
+
"""
|
|
1465
|
+
Convert an ERB template to Jinja2 with optional AI assistance.
|
|
1466
|
+
|
|
1467
|
+
Converts Chef ERB templates to Ansible Jinja2 format with optional
|
|
1468
|
+
AI-based validation and improvement for complex Ruby logic.
|
|
1469
|
+
"""
|
|
1470
|
+
click.echo(f"🔄 Converting template: {erb_path}")
|
|
1471
|
+
if ai:
|
|
1472
|
+
click.echo("✨ Using AI enhancement for complex conversions")
|
|
1473
|
+
else:
|
|
1474
|
+
click.echo("📝 Using rule-based conversion only")
|
|
1475
|
+
|
|
1476
|
+
from souschef.converters.template import convert_template_with_ai
|
|
1477
|
+
|
|
1478
|
+
try:
|
|
1479
|
+
result = convert_template_with_ai(erb_path, ai_service=None)
|
|
1480
|
+
|
|
1481
|
+
if result.get("success"):
|
|
1482
|
+
method = result.get("conversion_method", "unknown")
|
|
1483
|
+
click.echo(f"✅ Conversion successful ({method})")
|
|
1484
|
+
|
|
1485
|
+
if output:
|
|
1486
|
+
output_path = Path(output)
|
|
1487
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1488
|
+
output_path.write_text(result.get("jinja2_output", ""))
|
|
1489
|
+
click.echo(f"💾 Converted template saved to: {output}")
|
|
1490
|
+
else:
|
|
1491
|
+
click.echo("\nConverted Template:")
|
|
1492
|
+
click.echo("-" * 50)
|
|
1493
|
+
click.echo(result.get("jinja2_output", ""))
|
|
1494
|
+
click.echo("-" * 50)
|
|
1495
|
+
|
|
1496
|
+
if result.get("warnings"):
|
|
1497
|
+
click.echo("\n⚠️ Warnings:")
|
|
1498
|
+
for warning in result["warnings"]:
|
|
1499
|
+
click.echo(f" - {warning}")
|
|
1500
|
+
else:
|
|
1501
|
+
error_msg = result.get("error", "Unknown error")
|
|
1502
|
+
click.echo(f"❌ Conversion failed: {error_msg}", err=True)
|
|
1503
|
+
sys.exit(1)
|
|
1504
|
+
except Exception as e:
|
|
1505
|
+
click.echo(f"❌ Error converting template: {e}", err=True)
|
|
1506
|
+
sys.exit(1)
|
|
1507
|
+
|
|
1508
|
+
|
|
1227
1509
|
def main() -> NoReturn:
|
|
1228
1510
|
"""Run the CLI."""
|
|
1511
|
+
configure_logging()
|
|
1229
1512
|
cli()
|
|
1230
1513
|
sys.exit(0)
|
|
1231
1514
|
|