specfact-cli 0.4.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.

Potentially problematic release.


This version of specfact-cli might be problematic. Click here for more details.

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