cyguide 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.
Files changed (64) hide show
  1. cyguide/__init__.py +0 -0
  2. cyguide/app.py +154 -0
  3. cyguide/engine/__init__.py +0 -0
  4. cyguide/engine/adapter.py +40 -0
  5. cyguide/engine/canonicalize.py +56 -0
  6. cyguide/engine/diagnostics.py +327 -0
  7. cyguide/engine/executor.py +236 -0
  8. cyguide/engine/explainer.py +144 -0
  9. cyguide/engine/gateway.py +103 -0
  10. cyguide/engine/manifest.py +40 -0
  11. cyguide/engine/power_facade.py +436 -0
  12. cyguide/engine/recommendation.py +184 -0
  13. cyguide/engine/registry.py +107 -0
  14. cyguide/engine/reporter.py +217 -0
  15. cyguide/engine/store.py +655 -0
  16. cyguide/main.py +153 -0
  17. cyguide/modes/__init__.py +0 -0
  18. cyguide/modes/learning.py +68 -0
  19. cyguide/modes/power.py +47 -0
  20. cyguide/schemas/__init__.py +0 -0
  21. cyguide/schemas/base.py +37 -0
  22. cyguide/schemas/credential.py +33 -0
  23. cyguide/schemas/dns.py +65 -0
  24. cyguide/schemas/entity.py +18 -0
  25. cyguide/schemas/host_internal.py +48 -0
  26. cyguide/schemas/netblock.py +22 -0
  27. cyguide/schemas/network.py +55 -0
  28. cyguide/schemas/power.py +63 -0
  29. cyguide/schemas/tls.py +26 -0
  30. cyguide/schemas/vulnerability.py +35 -0
  31. cyguide/schemas/web.py +64 -0
  32. cyguide/ui/__init__.py +0 -0
  33. cyguide/ui/screens/__init__.py +0 -0
  34. cyguide/ui/screens/home.py +643 -0
  35. cyguide/ui/screens/learning.py +527 -0
  36. cyguide/ui/screens/power.py +787 -0
  37. cyguide/ui/screens/tool_browser.py +102 -0
  38. cyguide/ui/style.css +831 -0
  39. cyguide/ui/widgets/__init__.py +0 -0
  40. cyguide/ui/widgets/home.py +9 -0
  41. cyguide/ui/widgets/power.py +568 -0
  42. cyguide-1.0.0.dist-info/METADATA +166 -0
  43. cyguide-1.0.0.dist-info/RECORD +64 -0
  44. cyguide-1.0.0.dist-info/WHEEL +5 -0
  45. cyguide-1.0.0.dist-info/entry_points.txt +2 -0
  46. cyguide-1.0.0.dist-info/licenses/LICENSE +21 -0
  47. cyguide-1.0.0.dist-info/top_level.txt +2 -0
  48. tools/__init__.py +0 -0
  49. tools/gobuster/__init__.py +0 -0
  50. tools/gobuster/adapter.py +94 -0
  51. tools/gobuster/manifest.toml +34 -0
  52. tools/gobuster/wordlist.txt +11 -0
  53. tools/nikto/__init__.py +0 -0
  54. tools/nikto/adapter.py +144 -0
  55. tools/nikto/manifest.toml +34 -0
  56. tools/nmap/__init__.py +0 -0
  57. tools/nmap/adapter.py +83 -0
  58. tools/nmap/manifest.toml +41 -0
  59. tools/subfinder/__init__.py +0 -0
  60. tools/subfinder/adapter.py +63 -0
  61. tools/subfinder/manifest.toml +34 -0
  62. tools/whois/__init__.py +0 -0
  63. tools/whois/adapter.py +68 -0
  64. tools/whois/manifest.toml +29 -0
cyguide/__init__.py ADDED
File without changes
cyguide/app.py ADDED
@@ -0,0 +1,154 @@
1
+ """Main CyGuide TUI Application."""
2
+
3
+ import os
4
+ from textual.app import App, SystemCommand
5
+ from cyguide.ui.screens.home import DashboardScreen
6
+ from cyguide.ui.screens.learning import LearningModeScreen
7
+ from cyguide.ui.screens.tool_browser import ToolBrowserScreen
8
+ from cyguide.ui.screens.power import PowerModeScreen
9
+
10
+ from typing import Optional, Dict, Any, List
11
+ import subprocess
12
+
13
+ from cyguide.engine.registry import ToolRegistry
14
+ from cyguide.engine.store import GraphStore
15
+ from cyguide.engine.executor import Executor
16
+ from cyguide.engine.explainer import LearningExplainer, OllamaBackend
17
+ from cyguide.modes.learning import LearningModeEngine
18
+ from cyguide.modes.power import PowerModeEngine
19
+ from cyguide.engine.power_facade import PowerWorkspaceFacade
20
+
21
+ class CyGuideApp(App):
22
+ """The central orchestrator for the CyGuide TUI."""
23
+
24
+ CSS_PATH = "ui/style.css"
25
+ TITLE = "CyGuide"
26
+
27
+ # Register screens so they can be switched by name
28
+ SCREENS = {
29
+ "dashboard": DashboardScreen,
30
+ "tool_browser": ToolBrowserScreen,
31
+ "learning_mode": LearningModeScreen,
32
+ "power_mode": PowerModeScreen,
33
+ }
34
+
35
+ def __init__(self, db_path: str = "data/cyguide.db", use_ollama: bool = False, ollama_model: str = "gemma", tools_dir: str = None, **kwargs):
36
+ super().__init__(**kwargs)
37
+ self.db_path = db_path
38
+ self.use_ollama = use_ollama
39
+ self.ollama_model = ollama_model
40
+ self.tools_dir = tools_dir
41
+ self.selected_session_id: Optional[str] = None
42
+ self.selected_workspace_id: Optional[str] = None
43
+ self.user_env: Dict[str, str] = os.environ.copy()
44
+ self.user_shell: str = "/bin/bash"
45
+ # Ensure the directory for the database exists
46
+ db_dir = os.path.dirname(os.path.abspath(self.db_path))
47
+ os.makedirs(db_dir, exist_ok=True)
48
+
49
+ def detect_user_shell(self) -> str:
50
+ """Find the user's default shell."""
51
+ return os.environ.get("SHELL", "/bin/bash")
52
+
53
+ def get_shell_environment(self) -> Dict[str, str]:
54
+ """Capture the full login shell environment."""
55
+ shell = self.detect_user_shell()
56
+ try:
57
+ # Run a login shell to capture the fully resolved environment
58
+ result = subprocess.run(
59
+ [shell, "-l", "-c", "env"],
60
+ capture_output=True, text=True, timeout=5
61
+ )
62
+ env = {}
63
+ for line in result.stdout.splitlines():
64
+ if "=" in line:
65
+ key, _, val = line.partition("=")
66
+ env[key] = val
67
+ return env if env else os.environ.copy()
68
+ except Exception:
69
+ return os.environ.copy()
70
+
71
+ async def on_mount(self) -> None:
72
+ """Initialize the app with core components and the dashboard."""
73
+ self.user_shell = self.detect_user_shell()
74
+ self.user_env = self.get_shell_environment()
75
+
76
+ self.learning_mode_tool = "nmap" # Default
77
+ # 1. Initialize Engines
78
+ self.registry = ToolRegistry(tools_dir=self.tools_dir)
79
+ self.registry.load_tools()
80
+
81
+ self.store = GraphStore(self.db_path)
82
+ await self.store.initialize()
83
+
84
+ # Ensure singleton Learning Sandbox exists
85
+ self.learning_ws_id, self.learning_session_id = await self.store.get_or_create_learning_sandbox()
86
+
87
+ from cyguide.engine.recommendation import RecommendationEngine
88
+ self.recommendation_engine = RecommendationEngine(self.store)
89
+ self.store.on_upsert(self.recommendation_engine.handle_upsert)
90
+
91
+ self.executor = Executor(self.store)
92
+
93
+ # Setup Explainer
94
+ from dotenv import load_dotenv
95
+ load_dotenv()
96
+ if self.use_ollama:
97
+ self.explainer_backend = OllamaBackend(model=self.ollama_model)
98
+ else:
99
+ api_key = os.environ.get("CYGUIDE_API_KEY") or os.environ.get("GROQ_API_KEY")
100
+ if api_key:
101
+ from cyguide.engine.explainer import CloudAPIBackend
102
+ api_type = os.environ.get("CYGUIDE_API_TYPE", "openai")
103
+ if os.environ.get("GROQ_API_KEY") and not os.environ.get("CYGUIDE_API_BASE"):
104
+ api_base = "https://api.groq.com/openai/v1"
105
+ api_model = os.environ.get("CYGUIDE_API_MODEL", "llama-3.3-70b-versatile")
106
+ else:
107
+ api_base = os.environ.get("CYGUIDE_API_BASE", "https://api.openai.com/v1")
108
+ api_model = os.environ.get("CYGUIDE_API_MODEL", "gpt-4o")
109
+ self.explainer_backend = CloudAPIBackend(api_type, api_base, api_key, api_model)
110
+ else:
111
+ # Fallback to Ollama if no API key is provided and use_ollama is false
112
+ self.explainer_backend = OllamaBackend(model=self.ollama_model)
113
+
114
+ self.explainer = LearningExplainer(self.explainer_backend)
115
+
116
+ self.learning_engine = LearningModeEngine(self.registry, self.executor, self.explainer)
117
+ self.power_engine = PowerModeEngine(self.registry, self.executor)
118
+ self.power_facade = PowerWorkspaceFacade(self.store, self.executor, self.registry)
119
+ self.power_facade.user_env = self.user_env
120
+ self.power_facade.user_shell = self.user_shell
121
+
122
+ # 2. Setup UI
123
+ self.push_screen("dashboard")
124
+ self.animations_enabled = True
125
+
126
+ async def on_unmount(self) -> None:
127
+ """Cleanup resources before exit."""
128
+ if hasattr(self, "executor"):
129
+ await self.executor.shutdown()
130
+ if hasattr(self, "store"):
131
+ await self.store.close()
132
+
133
+ def action_toggle_animations(self) -> None:
134
+ """Toggle UI animations on or off via the command palette."""
135
+ self.animations_enabled = not self.animations_enabled
136
+ if self.animations_enabled:
137
+ self.remove_class("no-animations")
138
+ self.notify("Animations Enabled")
139
+ else:
140
+ self.add_class("no-animations")
141
+ self.notify("Animations Disabled")
142
+
143
+ def get_system_commands(self, screen):
144
+ """Add custom commands to the Command Palette (Ctrl+P)."""
145
+ yield from super().get_system_commands(screen)
146
+ yield SystemCommand(
147
+ "Toggle Animations",
148
+ "Turn sidebar animations on or off",
149
+ self.action_toggle_animations
150
+ )
151
+
152
+ if __name__ == "__main__":
153
+ app = CyGuideApp()
154
+ app.run()
File without changes
@@ -0,0 +1,40 @@
1
+ """Abstract base class for all tool plugins."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import AsyncIterator, List, Dict, Any
5
+ from cyguide.schemas.base import BaseFinding
6
+
7
+
8
+ class ToolAdapter(ABC):
9
+ """
10
+ Contract for tool contributors.
11
+ Every tool in tools/ must implement this in adapter.py.
12
+ """
13
+
14
+ @abstractmethod
15
+ def validate_install(self) -> bool:
16
+ """Check if the tool binary is available on the system."""
17
+ pass
18
+
19
+ @abstractmethod
20
+ def build_command(self, target: BaseFinding, params: Dict[str, Any]) -> List[str]:
21
+ """
22
+ Build the command line arguments for the tool.
23
+
24
+ Args:
25
+ target: The finding being scanned (e.g. a network.host)
26
+ params: User-provided flags or options from the TUI
27
+ """
28
+ pass
29
+
30
+ @abstractmethod
31
+ async def parse_output(self, raw_stdout: str, target: BaseFinding, context: Dict[str, Any] = None) -> AsyncIterator[BaseFinding]:
32
+ """
33
+ Parse tool output and yield standardized findings.
34
+
35
+ Args:
36
+ raw_stdout: The raw output string from the tool
37
+ target: The original target finding (for context/parenting)
38
+ context: Optional dictionary for tracking state across lines (e.g. current host)
39
+ """
40
+ pass
@@ -0,0 +1,56 @@
1
+ # PROTECTED FILE — See CONTRIBUTING.md before modifying.
2
+ # Changes to this file require team agreement.
3
+ """Normalization filters for finding data."""
4
+
5
+ import urllib.parse
6
+
7
+
8
+ def canonicalize(value):
9
+ """Apply normalization rules based on the type of value."""
10
+ if isinstance(value, str):
11
+ # General string cleaning
12
+ value = value.strip()
13
+
14
+ # Lowercase hostnames (heuristic: no spaces, contains dots or is localhost)
15
+ if "." in value or value.lower() == "localhost":
16
+ if " " not in value and ":" not in value:
17
+ return value.lower()
18
+
19
+ return value
20
+
21
+
22
+ def canonicalize_host(ip_or_name: str) -> str:
23
+ """Normalize IP addresses and hostnames."""
24
+ val = ip_or_name.strip().lower()
25
+ if val in ("localhost", "127.0.0.1", "::1"):
26
+ return "127.0.0.1"
27
+ # Basic IP normalization (lowercase IPv6, etc)
28
+ return val
29
+
30
+
31
+ def canonicalize_url(url: str) -> str:
32
+ """Normalize a URL: lowercase scheme/host, strip default ports."""
33
+ try:
34
+ parsed = urllib.parse.urlparse(url)
35
+ scheme = parsed.scheme.lower()
36
+ netloc = parsed.netloc.lower()
37
+
38
+ # Strip default ports
39
+ if scheme == "http" and netloc.endswith(":80"):
40
+ netloc = netloc[:-3]
41
+ elif scheme == "https" and netloc.endswith(":443"):
42
+ netloc = netloc[:-4]
43
+
44
+ return urllib.parse.urlunparse(
45
+ (scheme, netloc, parsed.path, parsed.params, parsed.query, parsed.fragment)
46
+ )
47
+ except Exception:
48
+ return url
49
+
50
+
51
+ def canonicalize_port(port) -> int:
52
+ """Ensure port is an integer."""
53
+ try:
54
+ return int(port)
55
+ except (ValueError, TypeError):
56
+ return 0
@@ -0,0 +1,327 @@
1
+ """Environment and dependency diagnostic checks for CyGuide."""
2
+
3
+ import os
4
+ import sys
5
+ import shutil
6
+ import platform
7
+ import asyncio
8
+ from typing import Optional, Dict, List, Any
9
+ from dataclasses import dataclass, field, asdict
10
+ from datetime import datetime, UTC
11
+
12
+ import httpx
13
+ from cyguide.engine.registry import ToolRegistry
14
+ from cyguide.engine.store import GraphStore
15
+
16
+
17
+ INSTALLATION_HINTS: Dict[str, str] = {
18
+ "nmap": "sudo apt install nmap (or: brew install nmap)",
19
+ "gobuster": "sudo apt install gobuster (or: go install github.com/OJ/gobuster/v3@v3.6.0)",
20
+ "nikto": "sudo apt install nikto (or: brew install nikto)",
21
+ "subfinder": "go install -v github.com/projectdiscovery/subfinder/v2/cmd/subfinder@v2.6.6",
22
+ "whois": "sudo apt install whois (or: brew install whois)",
23
+ }
24
+
25
+
26
+ @dataclass
27
+ class DiagnosticCheck:
28
+ name: str
29
+ category: str
30
+ status: str # "ok", "warning", "error", "info"
31
+ message: str
32
+ details: Optional[Dict[str, Any]] = None
33
+
34
+
35
+ @dataclass
36
+ class DiagnosticReport:
37
+ timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
38
+ system_info: Dict[str, Any] = field(default_factory=dict)
39
+ checks: List[DiagnosticCheck] = field(default_factory=list)
40
+ healthy: bool = True
41
+ tool_counts: Dict[str, int] = field(default_factory=dict)
42
+
43
+ def to_dict(self) -> Dict[str, Any]:
44
+ return {
45
+ "timestamp": self.timestamp,
46
+ "system_info": self.system_info,
47
+ "healthy": self.healthy,
48
+ "tool_counts": self.tool_counts,
49
+ "checks": [asdict(c) for c in self.checks]
50
+ }
51
+
52
+
53
+ async def run_diagnostics(
54
+ db_path: str = "data/cyguide.db",
55
+ tools_dir: Optional[str] = None
56
+ ) -> DiagnosticReport:
57
+ """Run all system, database, tool dependency, and explainer checks."""
58
+ report = DiagnosticReport()
59
+
60
+ # 1. System Environment
61
+ py_version = sys.version_info
62
+ py_version_str = f"{py_version.major}.{py_version.minor}.{py_version.micro}"
63
+ report.system_info = {
64
+ "python_version": py_version_str,
65
+ "platform": platform.platform(),
66
+ "executable": sys.executable,
67
+ }
68
+
69
+ if py_version >= (3, 11):
70
+ report.checks.append(DiagnosticCheck(
71
+ name="Python Version",
72
+ category="System",
73
+ status="ok",
74
+ message=f"Python {py_version_str} (>= 3.11 requirement satisfied)"
75
+ ))
76
+ else:
77
+ report.checks.append(DiagnosticCheck(
78
+ name="Python Version",
79
+ category="System",
80
+ status="error",
81
+ message=f"Python {py_version_str} is below minimum requirement 3.11"
82
+ ))
83
+ report.healthy = False
84
+
85
+ # Textual dependency check
86
+ try:
87
+ import textual
88
+ report.checks.append(DiagnosticCheck(
89
+ name="Textual TUI Framework",
90
+ category="System",
91
+ status="ok",
92
+ message=f"Textual v{textual.__version__} installed"
93
+ ))
94
+ except Exception as e:
95
+ report.checks.append(DiagnosticCheck(
96
+ name="Textual TUI Framework",
97
+ category="System",
98
+ status="error",
99
+ message=f"Failed to import textual: {str(e)}"
100
+ ))
101
+ report.healthy = False
102
+
103
+ # 2. Storage & SQLite Database Health
104
+ db_dir = os.path.dirname(os.path.abspath(db_path)) if db_path != ":memory:" else None
105
+ if db_dir:
106
+ os.makedirs(db_dir, exist_ok=True)
107
+ is_writable = os.access(db_dir, os.W_OK)
108
+ if is_writable:
109
+ report.checks.append(DiagnosticCheck(
110
+ name="Storage Directory",
111
+ category="Database",
112
+ status="ok",
113
+ message=f"Directory '{db_dir}' is writable"
114
+ ))
115
+ else:
116
+ report.checks.append(DiagnosticCheck(
117
+ name="Storage Directory",
118
+ category="Database",
119
+ status="error",
120
+ message=f"Directory '{db_dir}' is not writable"
121
+ ))
122
+ report.healthy = False
123
+
124
+ try:
125
+ store = GraphStore(db_path)
126
+ await store.initialize()
127
+
128
+ async with store._get_conn() as db:
129
+ cursor = await db.execute("PRAGMA integrity_check")
130
+ row = await cursor.fetchone()
131
+ integrity_status = row[0] if row else "unknown"
132
+
133
+ cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table'")
134
+ tables = [r[0] for r in await cursor.fetchall()]
135
+
136
+ await store.close()
137
+
138
+ if integrity_status == "ok":
139
+ report.checks.append(DiagnosticCheck(
140
+ name="SQLite Integrity",
141
+ category="Database",
142
+ status="ok",
143
+ message=f"Database '{db_path}' integrity verified ({len(tables)} tables present)",
144
+ details={"tables": tables}
145
+ ))
146
+ else:
147
+ report.checks.append(DiagnosticCheck(
148
+ name="SQLite Integrity",
149
+ category="Database",
150
+ status="error",
151
+ message=f"Database integrity check failed: {integrity_status}"
152
+ ))
153
+ report.healthy = False
154
+ except Exception as e:
155
+ report.checks.append(DiagnosticCheck(
156
+ name="SQLite Database",
157
+ category="Database",
158
+ status="error",
159
+ message=f"Failed to connect to database '{db_path}': {str(e)}"
160
+ ))
161
+ report.healthy = False
162
+
163
+ # 3. Security Tool Dependencies
164
+ registry = ToolRegistry(tools_dir=tools_dir)
165
+ registry.load_tools()
166
+ tool_names = registry.list_tools()
167
+
168
+ installed_count = 0
169
+ total_tools = len(tool_names)
170
+
171
+ for name in tool_names:
172
+ tool_data = registry.get_tool(name)
173
+ adapter = tool_data.get("adapter") if tool_data else None
174
+ manifest = tool_data.get("manifest") if tool_data else None
175
+ description = manifest.meta.description if manifest else ""
176
+
177
+ is_installed = False
178
+ binary_path = None
179
+
180
+ if adapter:
181
+ try:
182
+ is_installed = adapter.validate_install()
183
+ except Exception:
184
+ is_installed = False
185
+
186
+ binary_path = shutil.which(name)
187
+
188
+ if is_installed:
189
+ installed_count += 1
190
+ report.checks.append(DiagnosticCheck(
191
+ name=f"Tool: {name}",
192
+ category="Tools",
193
+ status="ok",
194
+ message=f"Installed at {binary_path or 'PATH'}",
195
+ details={"description": description, "binary": binary_path}
196
+ ))
197
+ else:
198
+ hint = INSTALLATION_HINTS.get(name, f"Install '{name}' using your system package manager.")
199
+ report.checks.append(DiagnosticCheck(
200
+ name=f"Tool: {name}",
201
+ category="Tools",
202
+ status="warning",
203
+ message=f"Not found in PATH. Install hint: {hint}",
204
+ details={"description": description, "install_hint": hint}
205
+ ))
206
+
207
+ report.tool_counts = {
208
+ "installed": installed_count,
209
+ "total": total_tools,
210
+ "missing": total_tools - installed_count
211
+ }
212
+
213
+ # 4. Explainer & AI Backends
214
+ # Ollama check
215
+ try:
216
+ async with httpx.AsyncClient(timeout=1.0) as client:
217
+ resp = await client.get("http://localhost:11434/api/tags")
218
+ if resp.status_code == 200:
219
+ models = [m.get("name") for m in resp.json().get("models", [])]
220
+ report.checks.append(DiagnosticCheck(
221
+ name="Ollama Local LLM",
222
+ category="Explainer",
223
+ status="ok",
224
+ message=f"Connected to local Ollama ({len(models)} models available: {', '.join(models[:3])})",
225
+ details={"models": models}
226
+ ))
227
+ else:
228
+ report.checks.append(DiagnosticCheck(
229
+ name="Ollama Local LLM",
230
+ category="Explainer",
231
+ status="info",
232
+ message="Ollama returned non-200 status (optional backend)"
233
+ ))
234
+ except Exception:
235
+ report.checks.append(DiagnosticCheck(
236
+ name="Ollama Local LLM",
237
+ category="Explainer",
238
+ status="info",
239
+ message="Ollama service is not running on http://localhost:11434 (optional backend)"
240
+ ))
241
+
242
+ # Cloud API Keys check
243
+ if os.getenv("CYGUIDE_API_KEY"):
244
+ report.checks.append(DiagnosticCheck(
245
+ name="Cloud LLM Keys",
246
+ category="Explainer",
247
+ status="ok",
248
+ message=(
249
+ f"CYGUIDE_API_KEY set (type="
250
+ f"{os.getenv('CYGUIDE_API_TYPE', 'openai')}, "
251
+ f"model={os.getenv('CYGUIDE_API_MODEL', 'gpt-4o')})"
252
+ )
253
+ ))
254
+ else:
255
+ cloud_keys = [
256
+ "GROQ_API_KEY",
257
+ "OPENAI_API_KEY",
258
+ "ANTHROPIC_API_KEY",
259
+ "OPENROUTER_API_KEY",
260
+ "GROK_API_KEY",
261
+ "GEMINI_API_KEY",
262
+ ]
263
+ configured_keys = [k for k in cloud_keys if os.getenv(k)]
264
+ if configured_keys:
265
+ report.checks.append(DiagnosticCheck(
266
+ name="Cloud LLM Keys",
267
+ category="Explainer",
268
+ status="ok",
269
+ message=f"Configured API keys: {', '.join(configured_keys)}"
270
+ ))
271
+ else:
272
+ report.checks.append(DiagnosticCheck(
273
+ name="Cloud LLM Keys",
274
+ category="Explainer",
275
+ status="info",
276
+ message="CYGUIDE_API_KEY is not set (optional backend)"
277
+ ))
278
+
279
+ return report
280
+
281
+
282
+ def format_terminal_report(report: DiagnosticReport) -> str:
283
+ """Format the diagnostic report for terminal display."""
284
+ lines = []
285
+ lines.append("=" * 68)
286
+ lines.append(" 🩺 CYGUIDE SYSTEM & ENVIRONMENT DIAGNOSTICS")
287
+ lines.append("=" * 68)
288
+ lines.append(f" Timestamp : {report.timestamp}")
289
+ lines.append(f" Platform : {report.system_info.get('platform', 'unknown')}")
290
+ lines.append(f" Python : {report.system_info.get('python_version', 'unknown')} ({report.system_info.get('executable', '')})")
291
+ lines.append("-" * 68)
292
+
293
+ # Group by category
294
+ categories = ["System", "Database", "Tools", "Explainer"]
295
+ for cat in categories:
296
+ cat_checks = [c for c in report.checks if c.category == cat]
297
+ if not cat_checks:
298
+ continue
299
+
300
+ header_icon = {
301
+ "System": "🖥️ ",
302
+ "Database": "💾",
303
+ "Tools": "🛠️ ",
304
+ "Explainer": "🤖"
305
+ }.get(cat, "🔍")
306
+
307
+ lines.append(f"\n{header_icon} {cat.upper()}")
308
+ for c in cat_checks:
309
+ if c.status == "ok":
310
+ badge = "[✓]"
311
+ elif c.status == "warning":
312
+ badge = "[!]"
313
+ elif c.status == "error":
314
+ badge = "[✗]"
315
+ else:
316
+ badge = "[-]"
317
+ lines.append(f" {badge:<4} {c.name:<24} : {c.message}")
318
+
319
+ lines.append("\n" + "=" * 68)
320
+ tool_stat = f"{report.tool_counts.get('installed', 0)}/{report.tool_counts.get('total', 0)} security tools installed"
321
+ if report.healthy:
322
+ lines.append(f" STATUS: HEALTHY ({tool_stat})")
323
+ else:
324
+ lines.append(f" STATUS: ATTENTION REQUIRED ({tool_stat})")
325
+ lines.append("=" * 68)
326
+
327
+ return "\n".join(lines)