mcp-souschef 2.8.0__py3-none-any.whl → 3.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {mcp_souschef-2.8.0.dist-info → mcp_souschef-3.2.0.dist-info}/METADATA +159 -384
  2. mcp_souschef-3.2.0.dist-info/RECORD +47 -0
  3. {mcp_souschef-2.8.0.dist-info → mcp_souschef-3.2.0.dist-info}/WHEEL +1 -1
  4. souschef/__init__.py +31 -7
  5. souschef/assessment.py +1451 -105
  6. souschef/ci/common.py +126 -0
  7. souschef/ci/github_actions.py +3 -92
  8. souschef/ci/gitlab_ci.py +2 -52
  9. souschef/ci/jenkins_pipeline.py +2 -59
  10. souschef/cli.py +149 -16
  11. souschef/converters/playbook.py +378 -138
  12. souschef/converters/resource.py +12 -11
  13. souschef/converters/template.py +177 -0
  14. souschef/core/__init__.py +6 -1
  15. souschef/core/metrics.py +313 -0
  16. souschef/core/path_utils.py +233 -19
  17. souschef/core/validation.py +53 -0
  18. souschef/deployment.py +71 -12
  19. souschef/generators/__init__.py +13 -0
  20. souschef/generators/repo.py +695 -0
  21. souschef/parsers/attributes.py +1 -1
  22. souschef/parsers/habitat.py +1 -1
  23. souschef/parsers/inspec.py +25 -2
  24. souschef/parsers/metadata.py +5 -3
  25. souschef/parsers/recipe.py +1 -1
  26. souschef/parsers/resource.py +1 -1
  27. souschef/parsers/template.py +1 -1
  28. souschef/server.py +1039 -121
  29. souschef/ui/app.py +486 -374
  30. souschef/ui/pages/ai_settings.py +74 -8
  31. souschef/ui/pages/cookbook_analysis.py +3216 -373
  32. souschef/ui/pages/validation_reports.py +274 -0
  33. mcp_souschef-2.8.0.dist-info/RECORD +0 -42
  34. souschef/converters/cookbook_specific.py.backup +0 -109
  35. {mcp_souschef-2.8.0.dist-info → mcp_souschef-3.2.0.dist-info}/entry_points.txt +0 -0
  36. {mcp_souschef-2.8.0.dist-info → mcp_souschef-3.2.0.dist-info}/licenses/LICENSE +0 -0
souschef/ci/common.py ADDED
@@ -0,0 +1,126 @@
1
+ """Common CI/CD analysis utilities for Chef cookbooks."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import yaml
7
+
8
+ from souschef.core.path_utils import _normalize_path
9
+
10
+
11
+ def _normalize_cookbook_base(cookbook_path: str | Path) -> Path:
12
+ """Normalise and resolve a cookbook path to prevent traversal."""
13
+ return (
14
+ _normalize_path(cookbook_path)
15
+ if isinstance(cookbook_path, str)
16
+ else cookbook_path.resolve()
17
+ )
18
+
19
+
20
+ def _initialise_patterns(base_path: Path) -> dict[str, Any]:
21
+ """Build initial pattern flags before deeper inspection."""
22
+ return {
23
+ "has_kitchen": (base_path / ".kitchen.yml").exists(),
24
+ "has_chefspec": (base_path / "spec").exists(),
25
+ "has_inspec": (base_path / "test" / "integration").exists(),
26
+ "has_berksfile": (base_path / "Berksfile").exists(),
27
+ "has_cookstyle": (base_path / ".cookstyle.yml").exists(),
28
+ "has_foodcritic": (base_path / ".foodcritic").exists(),
29
+ "lint_tools": [],
30
+ "kitchen_suites": [],
31
+ "kitchen_platforms": [],
32
+ }
33
+
34
+
35
+ def _detect_lint_tools(base_path: Path) -> list[str]:
36
+ """Detect linting tools configured in the cookbook directory."""
37
+ lint_tools: list[str] = []
38
+ if (base_path / ".foodcritic").exists():
39
+ lint_tools.append("foodcritic")
40
+ if (base_path / ".cookstyle.yml").exists():
41
+ lint_tools.append("cookstyle")
42
+ return lint_tools
43
+
44
+
45
+ def _has_chefspec_tests(base_path: Path) -> bool:
46
+ """Return True when ChefSpec tests are present."""
47
+ spec_dir = base_path / "spec"
48
+ return spec_dir.exists() and any(spec_dir.glob("**/*_spec.rb"))
49
+
50
+
51
+ def _parse_kitchen_configuration(kitchen_file: Path) -> tuple[list[str], list[str]]:
52
+ """Extract Test Kitchen suites and platforms from configuration."""
53
+ kitchen_suites: list[str] = []
54
+ kitchen_platforms: list[str] = []
55
+
56
+ try:
57
+ with kitchen_file.open() as file_handle: # nosonar
58
+ kitchen_config = yaml.safe_load(file_handle)
59
+ if not kitchen_config:
60
+ return kitchen_suites, kitchen_platforms
61
+
62
+ suites = kitchen_config.get("suites", [])
63
+ if suites:
64
+ kitchen_suites.extend(suite.get("name", "default") for suite in suites)
65
+
66
+ platforms = kitchen_config.get("platforms", [])
67
+ if platforms:
68
+ kitchen_platforms.extend(
69
+ platform.get("name", "unknown") for platform in platforms
70
+ )
71
+ except (yaml.YAMLError, OSError, KeyError, TypeError, AttributeError):
72
+ # Gracefully handle malformed .kitchen.yml - continue with empty config
73
+ # Catches: YAML syntax errors, file I/O errors, missing config keys,
74
+ # type mismatches in config structure, and missing dict attributes
75
+ return kitchen_suites, kitchen_platforms
76
+
77
+ return kitchen_suites, kitchen_platforms
78
+
79
+
80
+ def analyse_chef_ci_patterns(cookbook_path: str | Path) -> dict[str, Any]:
81
+ """
82
+ Analyse Chef cookbook for CI/CD patterns and testing configurations.
83
+
84
+ This function examines a Chef cookbook directory to detect various
85
+ testing and linting tools, as well as Test Kitchen configurations
86
+ including suites and platforms.
87
+
88
+ Args:
89
+ cookbook_path: Path to the Chef cookbook directory to analyse.
90
+
91
+ Returns:
92
+ Dictionary containing detected patterns with the following keys:
93
+ - has_kitchen (bool): Whether Test Kitchen is configured
94
+ (.kitchen.yml exists)
95
+ - has_chefspec (bool): Whether ChefSpec tests are present
96
+ (spec/**/*_spec.rb files)
97
+ - has_inspec (bool): Whether InSpec tests are present
98
+ (test/integration/ exists)
99
+ - has_berksfile (bool): Whether Berksfile exists
100
+ - lint_tools (list[str]): List of detected linting tools
101
+ ('foodcritic', 'cookstyle')
102
+ - kitchen_suites (list[str]): Names of Test Kitchen suites
103
+ found in .kitchen.yml
104
+
105
+ Note:
106
+ If .kitchen.yml is malformed or cannot be parsed, the function
107
+ continues with empty suite and platform lists rather than
108
+ raising an exception.
109
+
110
+ """
111
+ base_path = _normalize_cookbook_base(cookbook_path)
112
+
113
+ patterns: dict[str, Any] = _initialise_patterns(base_path)
114
+ patterns["lint_tools"] = _detect_lint_tools(base_path)
115
+ patterns["has_chefspec"] = _has_chefspec_tests(base_path)
116
+
117
+ kitchen_file = base_path / ".kitchen.yml"
118
+ if kitchen_file.exists():
119
+ suites, platforms = _parse_kitchen_configuration(kitchen_file)
120
+ patterns["kitchen_suites"] = suites
121
+ patterns["kitchen_platforms"] = platforms
122
+
123
+ # Add backward compatibility alias
124
+ patterns["test_suites"] = patterns["kitchen_suites"]
125
+
126
+ return patterns
@@ -11,6 +11,8 @@ from typing import Any
11
11
 
12
12
  import yaml
13
13
 
14
+ from souschef.ci.common import analyse_chef_ci_patterns
15
+
14
16
  # GitHub Actions constants
15
17
  ACTION_CHECKOUT = "actions/checkout@v4"
16
18
  ACTION_SETUP_RUBY = "ruby/setup-ruby@v1"
@@ -53,7 +55,7 @@ def generate_github_workflow_from_chef_ci(
53
55
  raise FileNotFoundError(f"Cookbook directory not found: {cookbook_path}")
54
56
 
55
57
  # Analyse Chef CI patterns
56
- patterns = _analyse_chef_ci_patterns(cookbook_dir)
58
+ patterns = analyse_chef_ci_patterns(cookbook_dir)
57
59
 
58
60
  # Build workflow structure
59
61
  workflow = _build_workflow_structure(
@@ -63,97 +65,6 @@ def generate_github_workflow_from_chef_ci(
63
65
  return yaml.dump(workflow, default_flow_style=False, sort_keys=False)
64
66
 
65
67
 
66
- def _analyse_chef_ci_patterns(cookbook_dir: Path) -> dict[str, Any]:
67
- """
68
- Analyse Chef cookbook for CI/CD patterns and testing configurations.
69
-
70
- This function examines a Chef cookbook directory to detect various
71
- testing and linting tools, as well as Test Kitchen configurations
72
- including suites and platforms.
73
-
74
- Args:
75
- cookbook_dir: Path to the Chef cookbook directory to analyse.
76
-
77
- Returns:
78
- Dictionary containing detected patterns with the following keys:
79
- - has_kitchen (bool): Whether Test Kitchen is configured
80
- (.kitchen.yml exists)
81
- - has_chefspec (bool): Whether ChefSpec tests are present
82
- (spec/**/*_spec.rb files)
83
- - has_cookstyle (bool): Whether Cookstyle is configured
84
- (.cookstyle.yml exists)
85
- - has_foodcritic (bool): Whether Foodcritic (legacy) is
86
- configured (.foodcritic exists)
87
- - kitchen_suites (list[str]): Names of Test Kitchen suites
88
- found in .kitchen.yml
89
- - kitchen_platforms (list[str]): Names of Test Kitchen
90
- platforms found in .kitchen.yml
91
-
92
- Note:
93
- If .kitchen.yml is malformed or cannot be parsed, the function
94
- continues with empty suite and platform lists rather than
95
- raising an exception.
96
-
97
- Example:
98
- >>> patterns = _analyze_chef_ci_patterns(Path("/path/to/cookbook"))
99
- >>> patterns["has_kitchen"]
100
- True
101
- >>> patterns["kitchen_suites"]
102
- ['default', 'integration']
103
-
104
- """
105
- patterns: dict[str, Any] = {
106
- "has_kitchen": False,
107
- "has_chefspec": False,
108
- "has_cookstyle": False,
109
- "has_foodcritic": False,
110
- "kitchen_suites": [],
111
- "kitchen_platforms": [],
112
- }
113
-
114
- # Check for Test Kitchen
115
- kitchen_yml = cookbook_dir / ".kitchen.yml"
116
- if kitchen_yml.exists():
117
- patterns["has_kitchen"] = True
118
- try:
119
- with kitchen_yml.open() as f:
120
- kitchen_config = yaml.safe_load(f)
121
- if kitchen_config:
122
- # Extract suites
123
- suites = kitchen_config.get("suites", [])
124
- if suites:
125
- patterns["kitchen_suites"] = [
126
- s.get("name", "default") for s in suites
127
- ]
128
- # Extract platforms
129
- platforms = kitchen_config.get("platforms", [])
130
- if platforms:
131
- patterns["kitchen_platforms"] = [
132
- p.get("name", "unknown") for p in platforms
133
- ]
134
- except (yaml.YAMLError, OSError, KeyError, TypeError, AttributeError):
135
- # Gracefully handle malformed .kitchen.yml - continue with empty config
136
- # Catches: YAML syntax errors, file I/O errors, missing config keys,
137
- # type mismatches in config structure, and missing dict attributes
138
- pass
139
-
140
- # Check for ChefSpec
141
- spec_dir = cookbook_dir / "spec"
142
- if spec_dir.exists() and any(spec_dir.glob("**/*_spec.rb")):
143
- patterns["has_chefspec"] = True
144
-
145
- # Check for Cookstyle
146
- cookstyle_yml = cookbook_dir / ".cookstyle.yml"
147
- if cookstyle_yml.exists():
148
- patterns["has_cookstyle"] = True
149
-
150
- # Check for Foodcritic (legacy)
151
- if (cookbook_dir / ".foodcritic").exists():
152
- patterns["has_foodcritic"] = True
153
-
154
- return patterns
155
-
156
-
157
68
  def _build_workflow_structure(
158
69
  workflow_name: str,
159
70
  patterns: dict[str, Any],
souschef/ci/gitlab_ci.py CHANGED
@@ -1,9 +1,8 @@
1
1
  """GitLab CI generation from Chef CI/CD patterns."""
2
2
 
3
- from pathlib import Path
4
3
  from typing import Any
5
4
 
6
- import yaml
5
+ from souschef.ci.common import analyse_chef_ci_patterns
7
6
 
8
7
 
9
8
  def generate_gitlab_ci_from_chef_ci(
@@ -28,7 +27,7 @@ def generate_gitlab_ci_from_chef_ci(
28
27
 
29
28
  """
30
29
  # Analyse Chef CI patterns
31
- ci_patterns = _analyse_chef_ci_patterns(cookbook_path)
30
+ ci_patterns = analyse_chef_ci_patterns(cookbook_path)
32
31
 
33
32
  # Generate CI configuration
34
33
  return _generate_gitlab_ci_yaml(
@@ -36,55 +35,6 @@ def generate_gitlab_ci_from_chef_ci(
36
35
  )
37
36
 
38
37
 
39
- def _analyse_chef_ci_patterns(cookbook_path: str) -> dict[str, Any]:
40
- """
41
- Analyse Chef cookbook for CI/CD patterns.
42
-
43
- Args:
44
- cookbook_path: Path to Chef cookbook.
45
-
46
- Returns:
47
- Dictionary of detected CI patterns.
48
-
49
- """
50
- base_path = Path(cookbook_path)
51
-
52
- patterns: dict[str, Any] = {
53
- "has_kitchen": (base_path / ".kitchen.yml").exists(),
54
- "has_chefspec": (base_path / "spec").exists(),
55
- "has_inspec": (base_path / "test" / "integration").exists(),
56
- "has_berksfile": (base_path / "Berksfile").exists(),
57
- "lint_tools": [],
58
- "test_suites": [],
59
- }
60
-
61
- # Detect linting tools
62
- lint_tools: list[str] = patterns["lint_tools"]
63
- if (base_path / ".foodcritic").exists():
64
- lint_tools.append("foodcritic")
65
- if (base_path / ".cookstyle.yml").exists():
66
- lint_tools.append("cookstyle")
67
-
68
- # Parse kitchen.yml for test suites
69
- kitchen_file = base_path / ".kitchen.yml"
70
- if kitchen_file.exists():
71
- try:
72
- test_suites: list[str] = patterns["test_suites"]
73
- with kitchen_file.open() as f:
74
- kitchen_config = yaml.safe_load(f)
75
- if kitchen_config and "suites" in kitchen_config:
76
- test_suites.extend(
77
- suite["name"] for suite in kitchen_config["suites"]
78
- )
79
- except (yaml.YAMLError, OSError, KeyError, TypeError, AttributeError):
80
- # Gracefully handle malformed .kitchen.yml - continue with empty
81
- # test suites. Catches: YAML syntax errors, file I/O errors,
82
- # missing config keys, type mismatches
83
- pass
84
-
85
- return patterns
86
-
87
-
88
38
  def _build_lint_jobs(ci_patterns: dict[str, Any], enable_artifacts: bool) -> list[str]:
89
39
  """Build lint job configurations."""
90
40
  jobs = []
@@ -1,9 +1,8 @@
1
1
  """Jenkins pipeline generation from Chef CI/CD patterns."""
2
2
 
3
- from pathlib import Path
4
3
  from typing import Any
5
4
 
6
- import yaml
5
+ from souschef.ci.common import analyse_chef_ci_patterns
7
6
 
8
7
 
9
8
  def generate_jenkinsfile_from_chef_ci(
@@ -29,7 +28,7 @@ def generate_jenkinsfile_from_chef_ci(
29
28
 
30
29
  """
31
30
  # Analyse Chef CI patterns
32
- ci_patterns = _analyse_chef_ci_patterns(cookbook_path)
31
+ ci_patterns = analyse_chef_ci_patterns(cookbook_path)
33
32
 
34
33
  if pipeline_type == "declarative":
35
34
  return _generate_declarative_pipeline(
@@ -39,62 +38,6 @@ def generate_jenkinsfile_from_chef_ci(
39
38
  return _generate_scripted_pipeline(pipeline_name, enable_parallel)
40
39
 
41
40
 
42
- def _analyse_chef_ci_patterns(cookbook_path: str) -> dict[str, Any]:
43
- """
44
- Analyse Chef cookbook for CI/CD patterns.
45
-
46
- Detects:
47
- - Test Kitchen configuration (.kitchen.yml)
48
- - ChefSpec tests (spec/)
49
- - InSpec tests (test/integration/)
50
- - Foodcritic/Cookstyle linting
51
- - Berksfile dependencies
52
-
53
- Args:
54
- cookbook_path: Path to Chef cookbook.
55
-
56
- Returns:
57
- Dictionary of detected CI patterns.
58
-
59
- """
60
- base_path = Path(cookbook_path)
61
-
62
- patterns: dict[str, Any] = {
63
- "has_kitchen": (base_path / ".kitchen.yml").exists(),
64
- "has_chefspec": (base_path / "spec").exists(),
65
- "has_inspec": (base_path / "test" / "integration").exists(),
66
- "has_berksfile": (base_path / "Berksfile").exists(),
67
- "lint_tools": [],
68
- "test_suites": [],
69
- }
70
-
71
- # Detect linting tools
72
- lint_tools: list[str] = patterns["lint_tools"]
73
- if (base_path / ".foodcritic").exists():
74
- lint_tools.append("foodcritic")
75
- if (base_path / ".cookstyle.yml").exists():
76
- lint_tools.append("cookstyle")
77
-
78
- # Parse kitchen.yml for test suites
79
- kitchen_file = base_path / ".kitchen.yml"
80
- if kitchen_file.exists():
81
- try:
82
- test_suites: list[str] = patterns["test_suites"]
83
- with kitchen_file.open() as f:
84
- kitchen_config = yaml.safe_load(f)
85
- if kitchen_config and "suites" in kitchen_config:
86
- test_suites.extend(
87
- suite["name"] for suite in kitchen_config["suites"]
88
- )
89
- except (yaml.YAMLError, OSError, KeyError, TypeError, AttributeError):
90
- # Gracefully handle malformed .kitchen.yml - continue with empty config
91
- # Catches: YAML syntax errors, file I/O errors, missing config keys,
92
- # type mismatches in config structure, and missing dict attributes
93
- pass
94
-
95
- return patterns
96
-
97
-
98
41
  def _create_lint_stage(ci_patterns: dict[str, Any]) -> str | None:
99
42
  """Create lint stage if lint tools are detected."""
100
43
  if not ci_patterns.get("lint_tools"):
souschef/cli.py CHANGED
@@ -11,7 +11,9 @@ from typing import NoReturn
11
11
 
12
12
  import click
13
13
 
14
+ from souschef import __version__
14
15
  from souschef.converters.playbook import generate_playbook_from_recipe
16
+ from souschef.core.path_utils import _normalize_path
15
17
  from souschef.profiling import (
16
18
  generate_cookbook_performance_report,
17
19
  profile_function,
@@ -40,8 +42,47 @@ CI_JOB_UNIT_TESTS = " • Unit Tests (ChefSpec)"
40
42
  CI_JOB_INTEGRATION_TESTS = " • Integration Tests (Test Kitchen)"
41
43
 
42
44
 
45
+ def _resolve_output_path(output: str | None, default_path: Path) -> Path:
46
+ """Normalise and validate output paths for generated files."""
47
+ try:
48
+ resolved_path = _normalize_path(output) if output else default_path.resolve()
49
+ except ValueError as exc: # noqa: TRY003
50
+ click.echo(f"Invalid output path: {exc}", err=True)
51
+ raise click.Abort() from exc
52
+
53
+ resolved_path.parent.mkdir(parents=True, exist_ok=True)
54
+ return resolved_path
55
+
56
+
57
+ def _safe_write_file(content: str, output: str | None, default_path: Path) -> Path:
58
+ """
59
+ Safely write content to a validated file path.
60
+
61
+ Args:
62
+ content: Content to write to file.
63
+ output: Optional user-specified output path.
64
+ default_path: Default path if output not specified.
65
+
66
+ Returns:
67
+ The path where content was written.
68
+
69
+ Raises:
70
+ click.Abort: If path validation or write fails.
71
+
72
+ """
73
+ validated_path = _resolve_output_path(output, default_path)
74
+ try:
75
+ # Separate validation from write to satisfy SonarQube path construction rules
76
+ with validated_path.open("w", encoding="utf-8") as f:
77
+ f.write(content)
78
+ except OSError as e:
79
+ click.echo(f"Error writing file: {e}", err=True)
80
+ raise click.Abort() from e
81
+ return validated_path
82
+
83
+
43
84
  @click.group()
44
- @click.version_option(version="0.1.0", prog_name="souschef")
85
+ @click.version_option(version=__version__, prog_name="souschef")
45
86
  def cli() -> None:
46
87
  """
47
88
  SousChef - Chef to Ansible conversion toolkit.
@@ -433,18 +474,21 @@ def generate_jenkinsfile(
433
474
 
434
475
  """
435
476
  try:
477
+ safe_cookbook_path = str(_normalize_path(cookbook_path))
436
478
  result = generate_jenkinsfile_from_chef(
437
- cookbook_path=cookbook_path,
479
+ cookbook_path=safe_cookbook_path,
438
480
  pipeline_type=pipeline_type,
439
481
  enable_parallel="yes" if parallel else "no",
440
482
  )
441
483
 
442
484
  # Determine output path
443
- output_path = Path(output) if output else Path.cwd() / "Jenkinsfile"
485
+ _resolve_output_path(output, default_path=Path.cwd() / "Jenkinsfile")
444
486
 
445
- # Write Jenkinsfile
446
- output_path.write_text(result)
447
- click.echo(f"✓ Generated {pipeline_type} Jenkinsfile: {output_path}")
487
+ # Write Jenkinsfile using safe write helper
488
+ written_path = _safe_write_file(
489
+ result, output, default_path=Path.cwd() / "Jenkinsfile"
490
+ )
491
+ click.echo(f"✓ Generated {pipeline_type} Jenkinsfile: {written_path}")
448
492
 
449
493
  # Show summary
450
494
  click.echo("\nGenerated Pipeline Stages:")
@@ -510,18 +554,18 @@ def generate_gitlab_ci(
510
554
 
511
555
  """
512
556
  try:
557
+ safe_cookbook_path = str(_normalize_path(cookbook_path))
513
558
  result = generate_gitlab_ci_from_chef(
514
- cookbook_path=cookbook_path,
559
+ cookbook_path=safe_cookbook_path,
515
560
  enable_cache="yes" if cache else "no",
516
561
  enable_artifacts="yes" if artifacts else "no",
517
562
  )
518
563
 
519
- # Determine output path
520
- output_path = Path(output) if output else Path.cwd() / ".gitlab-ci.yml"
521
-
522
- # Write GitLab CI config
523
- output_path.write_text(result)
524
- click.echo(f"✓ Generated GitLab CI configuration: {output_path}")
564
+ # Write GitLab CI config using safe write helper
565
+ written_path = _safe_write_file(
566
+ result, output, default_path=Path.cwd() / ".gitlab-ci.yml"
567
+ )
568
+ click.echo(f"✓ Generated GitLab CI configuration: {written_path}")
525
569
 
526
570
  # Show summary
527
571
  click.echo("\nGenerated CI Jobs:")
@@ -590,8 +634,9 @@ def generate_github_workflow(
590
634
 
591
635
  """
592
636
  try:
637
+ safe_cookbook_path = str(_normalize_path(cookbook_path))
593
638
  result = generate_github_workflow_from_chef(
594
- cookbook_path=cookbook_path,
639
+ cookbook_path=safe_cookbook_path,
595
640
  workflow_name=workflow_name,
596
641
  enable_cache="yes" if cache else "no",
597
642
  enable_artifacts="yes" if artifacts else "no",
@@ -599,11 +644,11 @@ def generate_github_workflow(
599
644
 
600
645
  # Determine output path
601
646
  if output:
602
- output_path = Path(output)
647
+ output_path = _resolve_output_path(output, Path.cwd() / "ci.yml")
603
648
  else:
604
649
  workflows_dir = Path.cwd() / ".github" / "workflows"
605
650
  workflows_dir.mkdir(parents=True, exist_ok=True)
606
- output_path = workflows_dir / "ci.yml"
651
+ output_path = _resolve_output_path(None, workflows_dir / "ci.yml")
607
652
 
608
653
  # Write workflow file
609
654
  output_path.write_text(result)
@@ -1077,6 +1122,94 @@ def convert_inspec(profile_path: str, output_path: str, output_format: str) -> N
1077
1122
  sys.exit(1)
1078
1123
 
1079
1124
 
1125
+ @cli.command("convert-cookbook")
1126
+ @click.option(
1127
+ "--cookbook-path",
1128
+ required=True,
1129
+ type=click.Path(exists=True),
1130
+ help="Path to the Chef cookbook directory",
1131
+ )
1132
+ @click.option(
1133
+ "--output-path",
1134
+ required=True,
1135
+ type=click.Path(),
1136
+ help="Directory where the Ansible role will be created",
1137
+ )
1138
+ @click.option(
1139
+ "--assessment-file",
1140
+ type=click.Path(exists=True),
1141
+ help="Path to JSON file with assessment results for optimization",
1142
+ )
1143
+ @click.option(
1144
+ "--role-name",
1145
+ help="Name for the Ansible role (defaults to cookbook name)",
1146
+ )
1147
+ @click.option(
1148
+ "--skip-templates",
1149
+ is_flag=True,
1150
+ help="Skip conversion of ERB templates to Jinja2",
1151
+ )
1152
+ @click.option(
1153
+ "--skip-attributes",
1154
+ is_flag=True,
1155
+ help="Skip conversion of attributes to Ansible variables",
1156
+ )
1157
+ @click.option(
1158
+ "--skip-recipes",
1159
+ is_flag=True,
1160
+ help="Skip conversion of recipes to Ansible tasks",
1161
+ )
1162
+ def convert_cookbook(
1163
+ cookbook_path: str,
1164
+ output_path: str,
1165
+ assessment_file: str | None = None,
1166
+ role_name: str | None = None,
1167
+ skip_templates: bool = False,
1168
+ skip_attributes: bool = False,
1169
+ skip_recipes: bool = False,
1170
+ ) -> None:
1171
+ r"""
1172
+ Convert an entire Chef cookbook to a complete Ansible role.
1173
+
1174
+ Performs comprehensive conversion including recipes, templates, attributes,
1175
+ and proper Ansible role structure. Can use assessment data for optimization.
1176
+
1177
+ Example:
1178
+ souschef convert-cookbook --cookbook-path /chef/cookbooks/nginx \\
1179
+ --output-path /ansible/roles \\
1180
+ --assessment-file assessment.json
1181
+
1182
+ """
1183
+ try:
1184
+ # Load assessment data if provided
1185
+ assessment_data = ""
1186
+ if assessment_file:
1187
+ assessment_path = Path(assessment_file)
1188
+ if assessment_path.exists():
1189
+ assessment_data = assessment_path.read_text()
1190
+ else:
1191
+ click.echo(f"Warning: Assessment file not found: {assessment_file}")
1192
+
1193
+ # Call server function
1194
+ from souschef.server import convert_cookbook_comprehensive
1195
+
1196
+ result = convert_cookbook_comprehensive(
1197
+ cookbook_path=cookbook_path,
1198
+ output_path=output_path,
1199
+ assessment_data=assessment_data,
1200
+ include_templates=not skip_templates,
1201
+ include_attributes=not skip_attributes,
1202
+ include_recipes=not skip_recipes,
1203
+ role_name=role_name or "",
1204
+ )
1205
+
1206
+ click.echo(result)
1207
+
1208
+ except Exception as e:
1209
+ click.echo(f"Error converting cookbook: {e}", err=True)
1210
+ sys.exit(1)
1211
+
1212
+
1080
1213
  @cli.command()
1081
1214
  @click.option("--port", default=8501, help="Port to run the Streamlit app on")
1082
1215
  def ui(port: int) -> None: