specfact-cli 0.4.2__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 (62) hide show
  1. specfact_cli/__init__.py +14 -0
  2. specfact_cli/agents/__init__.py +24 -0
  3. specfact_cli/agents/analyze_agent.py +392 -0
  4. specfact_cli/agents/base.py +95 -0
  5. specfact_cli/agents/plan_agent.py +202 -0
  6. specfact_cli/agents/registry.py +176 -0
  7. specfact_cli/agents/sync_agent.py +133 -0
  8. specfact_cli/analyzers/__init__.py +11 -0
  9. specfact_cli/analyzers/code_analyzer.py +796 -0
  10. specfact_cli/cli.py +396 -0
  11. specfact_cli/commands/__init__.py +7 -0
  12. specfact_cli/commands/enforce.py +88 -0
  13. specfact_cli/commands/import_cmd.py +365 -0
  14. specfact_cli/commands/init.py +125 -0
  15. specfact_cli/commands/plan.py +1089 -0
  16. specfact_cli/commands/repro.py +192 -0
  17. specfact_cli/commands/sync.py +408 -0
  18. specfact_cli/common/__init__.py +25 -0
  19. specfact_cli/common/logger_setup.py +654 -0
  20. specfact_cli/common/logging_utils.py +41 -0
  21. specfact_cli/common/text_utils.py +52 -0
  22. specfact_cli/common/utils.py +48 -0
  23. specfact_cli/comparators/__init__.py +11 -0
  24. specfact_cli/comparators/plan_comparator.py +391 -0
  25. specfact_cli/generators/__init__.py +14 -0
  26. specfact_cli/generators/plan_generator.py +105 -0
  27. specfact_cli/generators/protocol_generator.py +115 -0
  28. specfact_cli/generators/report_generator.py +200 -0
  29. specfact_cli/generators/workflow_generator.py +120 -0
  30. specfact_cli/importers/__init__.py +7 -0
  31. specfact_cli/importers/speckit_converter.py +773 -0
  32. specfact_cli/importers/speckit_scanner.py +711 -0
  33. specfact_cli/models/__init__.py +33 -0
  34. specfact_cli/models/deviation.py +105 -0
  35. specfact_cli/models/enforcement.py +150 -0
  36. specfact_cli/models/plan.py +97 -0
  37. specfact_cli/models/protocol.py +28 -0
  38. specfact_cli/modes/__init__.py +19 -0
  39. specfact_cli/modes/detector.py +126 -0
  40. specfact_cli/modes/router.py +153 -0
  41. specfact_cli/resources/semgrep/async.yml +285 -0
  42. specfact_cli/sync/__init__.py +12 -0
  43. specfact_cli/sync/repository_sync.py +279 -0
  44. specfact_cli/sync/speckit_sync.py +388 -0
  45. specfact_cli/utils/__init__.py +58 -0
  46. specfact_cli/utils/console.py +70 -0
  47. specfact_cli/utils/feature_keys.py +212 -0
  48. specfact_cli/utils/git.py +241 -0
  49. specfact_cli/utils/github_annotations.py +399 -0
  50. specfact_cli/utils/ide_setup.py +382 -0
  51. specfact_cli/utils/prompts.py +180 -0
  52. specfact_cli/utils/structure.py +497 -0
  53. specfact_cli/utils/yaml_utils.py +200 -0
  54. specfact_cli/validators/__init__.py +20 -0
  55. specfact_cli/validators/fsm.py +262 -0
  56. specfact_cli/validators/repro_checker.py +759 -0
  57. specfact_cli/validators/schema.py +196 -0
  58. specfact_cli-0.4.2.dist-info/METADATA +370 -0
  59. specfact_cli-0.4.2.dist-info/RECORD +62 -0
  60. specfact_cli-0.4.2.dist-info/WHEEL +4 -0
  61. specfact_cli-0.4.2.dist-info/entry_points.txt +2 -0
  62. specfact_cli-0.4.2.dist-info/licenses/LICENSE.md +61 -0
@@ -0,0 +1,382 @@
1
+ """
2
+ IDE Setup Utilities - Detect IDE and copy prompt templates to IDE-specific locations.
3
+
4
+ This module provides utilities for detecting IDE type, processing prompt templates,
5
+ and copying them to IDE-specific locations for slash command integration.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ from pathlib import Path
13
+ from typing import Literal
14
+
15
+ import yaml
16
+ from beartype import beartype
17
+ from icontract import ensure, require
18
+ from rich.console import Console
19
+
20
+
21
+ console = Console()
22
+
23
+ # IDE configuration map (from Spec-Kit)
24
+ IDE_CONFIG: dict[str, dict[str, str | bool | None]] = {
25
+ "claude": {
26
+ "name": "Claude Code",
27
+ "folder": ".claude/commands/",
28
+ "format": "md",
29
+ "settings_file": None,
30
+ },
31
+ "copilot": {
32
+ "name": "GitHub Copilot",
33
+ "folder": ".github/prompts/",
34
+ "format": "prompt.md",
35
+ "settings_file": ".vscode/settings.json",
36
+ },
37
+ "vscode": {
38
+ "name": "VS Code",
39
+ "folder": ".github/prompts/",
40
+ "format": "prompt.md",
41
+ "settings_file": ".vscode/settings.json",
42
+ },
43
+ "cursor": {
44
+ "name": "Cursor",
45
+ "folder": ".cursor/commands/",
46
+ "format": "md",
47
+ "settings_file": None,
48
+ },
49
+ "gemini": {
50
+ "name": "Gemini CLI",
51
+ "folder": ".gemini/commands/",
52
+ "format": "toml",
53
+ "settings_file": None,
54
+ },
55
+ "qwen": {
56
+ "name": "Qwen Code",
57
+ "folder": ".qwen/commands/",
58
+ "format": "toml",
59
+ "settings_file": None,
60
+ },
61
+ "opencode": {
62
+ "name": "opencode",
63
+ "folder": ".opencode/command/",
64
+ "format": "md",
65
+ "settings_file": None,
66
+ },
67
+ "windsurf": {
68
+ "name": "Windsurf",
69
+ "folder": ".windsurf/workflows/",
70
+ "format": "md",
71
+ "settings_file": None,
72
+ },
73
+ "kilocode": {
74
+ "name": "Kilo Code",
75
+ "folder": ".kilocode/workflows/",
76
+ "format": "md",
77
+ "settings_file": None,
78
+ },
79
+ "auggie": {
80
+ "name": "Auggie CLI",
81
+ "folder": ".augment/commands/",
82
+ "format": "md",
83
+ "settings_file": None,
84
+ },
85
+ "roo": {
86
+ "name": "Roo Code",
87
+ "folder": ".roo/commands/",
88
+ "format": "md",
89
+ "settings_file": None,
90
+ },
91
+ "codebuddy": {
92
+ "name": "CodeBuddy",
93
+ "folder": ".codebuddy/commands/",
94
+ "format": "md",
95
+ "settings_file": None,
96
+ },
97
+ "amp": {
98
+ "name": "Amp",
99
+ "folder": ".agents/commands/",
100
+ "format": "md",
101
+ "settings_file": None,
102
+ },
103
+ "q": {
104
+ "name": "Amazon Q Developer",
105
+ "folder": ".amazonq/prompts/",
106
+ "format": "md",
107
+ "settings_file": None,
108
+ },
109
+ }
110
+
111
+ # Commands available in SpecFact
112
+ SPECFACT_COMMANDS = [
113
+ "specfact-import-from-code",
114
+ "specfact-plan-init",
115
+ "specfact-plan-select",
116
+ "specfact-plan-promote",
117
+ "specfact-plan-compare",
118
+ "specfact-sync",
119
+ ]
120
+
121
+
122
+ @beartype
123
+ @require(lambda ide: ide in IDE_CONFIG or ide == "auto", "IDE must be valid or 'auto'")
124
+ def detect_ide(ide: str = "auto") -> str:
125
+ """
126
+ Detect IDE type from environment or use provided value.
127
+
128
+ Args:
129
+ ide: IDE identifier or "auto" for auto-detection
130
+
131
+ Returns:
132
+ IDE identifier (e.g., "cursor", "vscode", "copilot")
133
+
134
+ Examples:
135
+ >>> detect_ide("cursor")
136
+ 'cursor'
137
+ >>> detect_ide("auto") # Auto-detect from environment
138
+ 'vscode'
139
+ """
140
+ if ide != "auto":
141
+ return ide
142
+
143
+ # Auto-detect from environment variables
144
+ # Check Cursor FIRST (before VS Code) since Cursor sets VSCODE_* variables too
145
+ # Cursor-specific variables take priority
146
+ # Cursor sets: CURSOR_AGENT, CURSOR_TRACE_ID, CURSOR_PID, CURSOR_INJECTION, CHROME_DESKTOP=cursor.desktop
147
+ if (
148
+ os.environ.get("CURSOR_AGENT")
149
+ or os.environ.get("CURSOR_TRACE_ID")
150
+ or os.environ.get("CURSOR_PID")
151
+ or os.environ.get("CURSOR_INJECTION")
152
+ or os.environ.get("CHROME_DESKTOP") == "cursor.desktop"
153
+ ):
154
+ return "cursor"
155
+ # VS Code / Copilot
156
+ if os.environ.get("VSCODE_PID") or os.environ.get("VSCODE_INJECTION"):
157
+ return "vscode"
158
+ # Claude Code
159
+ if os.environ.get("CLAUDE_PID"):
160
+ return "claude"
161
+ # Default to VS Code if no detection
162
+ return "vscode"
163
+
164
+
165
+ @beartype
166
+ @require(lambda template_path: template_path.exists(), "Template path must exist")
167
+ @require(lambda template_path: template_path.is_file(), "Template path must be a file")
168
+ @ensure(
169
+ lambda result: isinstance(result, dict) and "description" in result and "content" in result,
170
+ "Result must be dict with description and content",
171
+ )
172
+ def read_template(template_path: Path) -> dict[str, str]:
173
+ """
174
+ Read prompt template and extract YAML frontmatter and content.
175
+
176
+ Args:
177
+ template_path: Path to template file (.md)
178
+
179
+ Returns:
180
+ Dict with "description" (from frontmatter) and "content" (markdown body)
181
+
182
+ Examples:
183
+ >>> template = read_template(Path("resources/prompts/specfact-import-from-code.md"))
184
+ >>> "description" in template
185
+ True
186
+ >>> "content" in template
187
+ True
188
+ """
189
+ content = template_path.read_text(encoding="utf-8")
190
+
191
+ # Extract YAML frontmatter
192
+ frontmatter_match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)$", content, re.DOTALL)
193
+ if frontmatter_match:
194
+ frontmatter_str = frontmatter_match.group(1)
195
+ body = frontmatter_match.group(2)
196
+ frontmatter = yaml.safe_load(frontmatter_str) or {}
197
+ description = frontmatter.get("description", "")
198
+ else:
199
+ # No frontmatter, use entire content as body
200
+ description = ""
201
+ body = content
202
+
203
+ return {"description": description, "content": body}
204
+
205
+
206
+ @beartype
207
+ @require(lambda content: isinstance(content, str), "Content must be string")
208
+ @require(lambda format_type: format_type in ("md", "toml", "prompt.md"), "Format must be md, toml, or prompt.md")
209
+ def process_template(content: str, description: str, format_type: Literal["md", "toml", "prompt.md"]) -> str:
210
+ """
211
+ Process template content for specific IDE format.
212
+
213
+ Args:
214
+ content: Template markdown content
215
+ description: Template description (from frontmatter)
216
+ format_type: Target format (md, toml, or prompt.md)
217
+
218
+ Returns:
219
+ Processed template content for target format
220
+
221
+ Examples:
222
+ >>> process_template("# Title\n$ARGUMENTS", "Test", "md")
223
+ '# Title\n$ARGUMENTS'
224
+ >>> result = process_template("# Title\n$ARGUMENTS", "Test", "toml")
225
+ >>> "description" in result and "prompt" in result
226
+ True
227
+ """
228
+ # Replace placeholders based on format
229
+ if format_type == "toml":
230
+ # TOML format: Replace $ARGUMENTS with {{args}}, escape backslashes
231
+ processed = content.replace("$ARGUMENTS", "{{args}}")
232
+ processed = processed.replace("\\", "\\\\")
233
+ # Wrap in TOML structure
234
+ return f'description = "{description}"\n\nprompt = """\n{processed}\n"""'
235
+ if format_type == "prompt.md":
236
+ # VS Code/Copilot format: Keep $ARGUMENTS, add .prompt.md extension
237
+ return content
238
+ # Markdown format: Keep $ARGUMENTS as-is
239
+ return content
240
+
241
+
242
+ @beartype
243
+ @require(lambda repo_path: repo_path.exists(), "Repo path must exist")
244
+ @require(lambda repo_path: repo_path.is_dir(), "Repo path must be a directory")
245
+ @require(lambda ide: ide in IDE_CONFIG, "IDE must be valid")
246
+ @ensure(
247
+ lambda result: isinstance(result, tuple)
248
+ and len(result) == 2
249
+ and (result[1] is None or (isinstance(result[1], Path) and result[1].exists())),
250
+ "Settings file path must exist if returned",
251
+ )
252
+ def copy_templates_to_ide(
253
+ repo_path: Path, ide: str, templates_dir: Path, force: bool = False
254
+ ) -> tuple[list[Path], Path | None]:
255
+ """
256
+ Copy prompt templates to IDE-specific locations.
257
+
258
+ Args:
259
+ repo_path: Repository root path
260
+ ide: IDE identifier
261
+ templates_dir: Directory containing prompt templates
262
+ force: Overwrite existing files
263
+
264
+ Returns:
265
+ Tuple of (copied_file_paths, settings_file_path or None)
266
+
267
+ Examples:
268
+ >>> copied, settings = copy_templates_to_ide(Path("."), "cursor", Path("resources/prompts"))
269
+ >>> len(copied) > 0
270
+ True
271
+ """
272
+ config = IDE_CONFIG[ide]
273
+ ide_folder = str(config["folder"])
274
+ format_type = str(config["format"])
275
+ settings_file = config.get("settings_file")
276
+ if settings_file is not None and not isinstance(settings_file, str):
277
+ settings_file = None
278
+
279
+ # Create IDE directory
280
+ ide_dir = repo_path / ide_folder
281
+ ide_dir.mkdir(parents=True, exist_ok=True)
282
+
283
+ copied_files = []
284
+
285
+ # Copy each template
286
+ for command in SPECFACT_COMMANDS:
287
+ template_path = templates_dir / f"{command}.md"
288
+ if not template_path.exists():
289
+ console.print(f"[yellow]Warning:[/yellow] Template not found: {template_path}")
290
+ continue
291
+
292
+ # Read and process template
293
+ template_data = read_template(template_path)
294
+ processed_content = process_template(template_data["content"], template_data["description"], format_type) # type: ignore[arg-type]
295
+
296
+ # Determine output filename
297
+ if format_type == "prompt.md":
298
+ output_filename = f"{command}.prompt.md"
299
+ elif format_type == "toml":
300
+ output_filename = f"{command}.toml"
301
+ else:
302
+ output_filename = f"{command}.md"
303
+
304
+ output_path = ide_dir / output_filename
305
+
306
+ # Check if file exists
307
+ if output_path.exists() and not force:
308
+ console.print(f"[yellow]Skipping:[/yellow] {output_path} (already exists, use --force to overwrite)")
309
+ continue
310
+
311
+ # Write processed template
312
+ output_path.write_text(processed_content, encoding="utf-8")
313
+ copied_files.append(output_path)
314
+ console.print(f"[green]Copied:[/green] {output_path}")
315
+
316
+ # Handle VS Code settings if needed
317
+ settings_path = None
318
+ if settings_file and isinstance(settings_file, str):
319
+ settings_path = create_vscode_settings(repo_path, settings_file)
320
+
321
+ return (copied_files, settings_path)
322
+
323
+
324
+ @beartype
325
+ @require(lambda repo_path: repo_path.exists(), "Repo path must exist")
326
+ @require(lambda repo_path: repo_path.is_dir(), "Repo path must be a directory")
327
+ @ensure(lambda result: result is None or result.exists(), "Settings file must exist if returned")
328
+ def create_vscode_settings(repo_path: Path, settings_file: str) -> Path | None:
329
+ """
330
+ Create or merge VS Code settings.json with prompt file recommendations.
331
+
332
+ Args:
333
+ repo_path: Repository root path
334
+ settings_file: Settings file path (e.g., ".vscode/settings.json")
335
+
336
+ Returns:
337
+ Path to settings file, or None if not VS Code/Copilot
338
+
339
+ Examples:
340
+ >>> settings = create_vscode_settings(Path("."), ".vscode/settings.json")
341
+ >>> settings is not None
342
+ True
343
+ """
344
+ import json
345
+
346
+ settings_path = repo_path / settings_file
347
+ settings_dir = settings_path.parent
348
+ settings_dir.mkdir(parents=True, exist_ok=True)
349
+
350
+ # Generate prompt file recommendations
351
+ prompt_files = [f".github/prompts/{cmd}.prompt.md" for cmd in SPECFACT_COMMANDS]
352
+
353
+ # Load existing settings or create new
354
+ if settings_path.exists():
355
+ try:
356
+ with open(settings_path, encoding="utf-8") as f:
357
+ existing_settings = json.load(f)
358
+ except (json.JSONDecodeError, FileNotFoundError):
359
+ existing_settings = {}
360
+ else:
361
+ existing_settings = {}
362
+
363
+ # Merge chat.promptFilesRecommendations
364
+ if "chat" not in existing_settings:
365
+ existing_settings["chat"] = {}
366
+
367
+ existing_recommendations = existing_settings["chat"].get("promptFilesRecommendations", [])
368
+ merged_recommendations = list(set(existing_recommendations + prompt_files))
369
+ existing_settings["chat"]["promptFilesRecommendations"] = merged_recommendations
370
+
371
+ # Write merged settings
372
+ with open(settings_path, "w", encoding="utf-8") as f:
373
+ json.dump(existing_settings, f, indent=4)
374
+ f.write("\n")
375
+
376
+ # Ensure file exists before returning (satisfies contract)
377
+ if not settings_path.exists():
378
+ console.print(f"[yellow]Warning:[/yellow] Settings file not created: {settings_path}")
379
+ return None
380
+
381
+ console.print(f"[green]Updated:[/green] {settings_path}")
382
+ return settings_path
@@ -0,0 +1,180 @@
1
+ """Interactive prompt utilities for CLI commands."""
2
+
3
+ from typing import Any
4
+
5
+ from beartype import beartype
6
+ from icontract import ensure, require
7
+ from rich.console import Console
8
+ from rich.prompt import Confirm, Prompt
9
+ from rich.table import Table
10
+
11
+
12
+ console = Console()
13
+
14
+
15
+ @beartype
16
+ @require(lambda message: isinstance(message, str) and len(message) > 0, "Message must be non-empty string")
17
+ @require(lambda default: default is None or isinstance(default, str), "Default must be None or string")
18
+ @ensure(lambda result: isinstance(result, str), "Must return string")
19
+ def prompt_text(message: str, default: str | None = None, required: bool = True) -> str:
20
+ """
21
+ Prompt user for text input.
22
+
23
+ Args:
24
+ message: Prompt message
25
+ default: Default value
26
+ required: Whether input is required
27
+
28
+ Returns:
29
+ User input string
30
+ """
31
+ while True:
32
+ result = Prompt.ask(message, default=default if default else "")
33
+ if result or not required:
34
+ return result
35
+ console.print("[yellow]This field is required[/yellow]")
36
+
37
+
38
+ @beartype
39
+ @require(lambda message: isinstance(message, str) and len(message) > 0, "Message must be non-empty string")
40
+ @require(lambda separator: isinstance(separator, str) and len(separator) > 0, "Separator must be non-empty string")
41
+ @ensure(lambda result: isinstance(result, list), "Must return list")
42
+ @ensure(lambda result: all(isinstance(item, str) for item in result), "All items must be strings")
43
+ def prompt_list(message: str, separator: str = ",") -> list[str]:
44
+ """
45
+ Prompt user for comma-separated list input.
46
+
47
+ Args:
48
+ message: Prompt message
49
+ separator: List item separator
50
+
51
+ Returns:
52
+ List of strings
53
+ """
54
+ result = Prompt.ask(f"{message} (comma-separated)")
55
+ if not result:
56
+ return []
57
+ return [item.strip() for item in result.split(separator) if item.strip()]
58
+
59
+
60
+ @beartype
61
+ @require(lambda message: isinstance(message, str) and len(message) > 0, "Message must be non-empty string")
62
+ @ensure(lambda result: isinstance(result, dict), "Must return dictionary")
63
+ def prompt_dict(message: str) -> dict[str, Any]:
64
+ """
65
+ Prompt user for key:value pairs.
66
+
67
+ Args:
68
+ message: Prompt message
69
+
70
+ Returns:
71
+ Dictionary of key-value pairs
72
+ """
73
+ console.print(f"\n[bold]{message}[/bold]")
74
+ console.print("Enter key:value pairs (one per line, empty line to finish)")
75
+
76
+ result = {}
77
+ while True:
78
+ line = Prompt.ask(" ", default="")
79
+ if not line:
80
+ break
81
+
82
+ if ":" not in line:
83
+ console.print("[yellow]Format should be key:value[/yellow]")
84
+ continue
85
+
86
+ key, value = line.split(":", 1)
87
+ key = key.strip()
88
+ value = value.strip()
89
+
90
+ # Try to convert to number if possible
91
+ try:
92
+ if "." in value:
93
+ result[key] = float(value)
94
+ else:
95
+ result[key] = int(value)
96
+ except ValueError:
97
+ result[key] = value
98
+
99
+ return result
100
+
101
+
102
+ @beartype
103
+ @require(lambda message: isinstance(message, str) and len(message) > 0, "Message must be non-empty string")
104
+ @ensure(lambda result: isinstance(result, bool), "Must return boolean")
105
+ def prompt_confirm(message: str, default: bool = False) -> bool:
106
+ """
107
+ Prompt user for yes/no confirmation.
108
+
109
+ Args:
110
+ message: Prompt message
111
+ default: Default value
112
+
113
+ Returns:
114
+ True if confirmed, False otherwise
115
+ """
116
+ return Confirm.ask(message, default=default)
117
+
118
+
119
+ @beartype
120
+ @require(lambda title: isinstance(title, str) and len(title) > 0, "Title must be non-empty string")
121
+ @require(lambda data: isinstance(data, dict), "Data must be dictionary")
122
+ def display_summary(title: str, data: dict[str, Any]) -> None:
123
+ """
124
+ Display a summary table.
125
+
126
+ Args:
127
+ title: Table title
128
+ data: Data to display
129
+ """
130
+ table = Table(title=title, show_header=True, header_style="bold cyan")
131
+ table.add_column("Field", style="cyan")
132
+ table.add_column("Value", style="green")
133
+
134
+ for key, value in data.items():
135
+ if isinstance(value, list):
136
+ value_str = ", ".join(str(v) for v in value)
137
+ elif isinstance(value, dict):
138
+ value_str = ", ".join(f"{k}={v}" for k, v in value.items())
139
+ else:
140
+ value_str = str(value)
141
+ table.add_row(key, value_str)
142
+
143
+ console.print(table)
144
+
145
+
146
+ @beartype
147
+ @require(lambda message: isinstance(message, str) and len(message) > 0, "Message must be non-empty string")
148
+ def print_success(message: str) -> None:
149
+ """Print success message."""
150
+ console.print(f"[bold green]✅ {message}[/bold green]")
151
+
152
+
153
+ @beartype
154
+ @require(lambda message: isinstance(message, str) and len(message) > 0, "Message must be non-empty string")
155
+ def print_error(message: str) -> None:
156
+ """Print error message."""
157
+ console.print(f"[bold red]❌ {message}[/bold red]")
158
+
159
+
160
+ @beartype
161
+ @require(lambda message: isinstance(message, str) and len(message) > 0, "Message must be non-empty string")
162
+ def print_warning(message: str) -> None:
163
+ """Print warning message."""
164
+ console.print(f"[bold yellow]⚠️ {message}[/bold yellow]")
165
+
166
+
167
+ @beartype
168
+ @require(lambda message: isinstance(message, str) and len(message) > 0, "Message must be non-empty string")
169
+ def print_info(message: str) -> None:
170
+ """Print info message."""
171
+ console.print(f"[bold blue]ℹ️ {message}[/bold blue]")
172
+
173
+
174
+ @beartype
175
+ @require(lambda title: isinstance(title, str) and len(title) > 0, "Title must be non-empty string")
176
+ def print_section(title: str) -> None:
177
+ """Print section header."""
178
+ console.print(f"\n[bold cyan]{'=' * 60}[/bold cyan]")
179
+ console.print(f"[bold cyan]{title}[/bold cyan]")
180
+ console.print(f"[bold cyan]{'=' * 60}[/bold cyan]\n")