souleyez 2.22.0__py3-none-any.whl → 2.27.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 souleyez might be problematic. Click here for more details.

Files changed (37) hide show
  1. souleyez/__init__.py +1 -1
  2. souleyez/assets/__init__.py +1 -0
  3. souleyez/assets/souleyez-icon.png +0 -0
  4. souleyez/core/msf_sync_manager.py +15 -5
  5. souleyez/core/tool_chaining.py +126 -26
  6. souleyez/detection/validator.py +4 -2
  7. souleyez/docs/README.md +2 -2
  8. souleyez/docs/user-guide/configuration.md +1 -1
  9. souleyez/docs/user-guide/installation.md +14 -1
  10. souleyez/engine/background.py +620 -154
  11. souleyez/engine/result_handler.py +262 -1
  12. souleyez/engine/worker_manager.py +98 -2
  13. souleyez/main.py +103 -4
  14. souleyez/parsers/crackmapexec_parser.py +101 -43
  15. souleyez/parsers/dnsrecon_parser.py +50 -35
  16. souleyez/parsers/enum4linux_parser.py +101 -21
  17. souleyez/parsers/http_fingerprint_parser.py +319 -0
  18. souleyez/parsers/hydra_parser.py +56 -5
  19. souleyez/parsers/impacket_parser.py +123 -44
  20. souleyez/parsers/john_parser.py +47 -14
  21. souleyez/parsers/msf_parser.py +20 -5
  22. souleyez/parsers/nmap_parser.py +48 -27
  23. souleyez/parsers/smbmap_parser.py +39 -23
  24. souleyez/parsers/sqlmap_parser.py +18 -9
  25. souleyez/parsers/theharvester_parser.py +21 -13
  26. souleyez/plugins/http_fingerprint.py +598 -0
  27. souleyez/plugins/nuclei.py +41 -17
  28. souleyez/ui/interactive.py +99 -7
  29. souleyez/ui/setup_wizard.py +93 -5
  30. souleyez/ui/tool_setup.py +52 -52
  31. souleyez/utils/tool_checker.py +45 -5
  32. {souleyez-2.22.0.dist-info → souleyez-2.27.0.dist-info}/METADATA +16 -3
  33. {souleyez-2.22.0.dist-info → souleyez-2.27.0.dist-info}/RECORD +37 -33
  34. {souleyez-2.22.0.dist-info → souleyez-2.27.0.dist-info}/WHEEL +0 -0
  35. {souleyez-2.22.0.dist-info → souleyez-2.27.0.dist-info}/entry_points.txt +0 -0
  36. {souleyez-2.22.0.dist-info → souleyez-2.27.0.dist-info}/licenses/LICENSE +0 -0
  37. {souleyez-2.22.0.dist-info → souleyez-2.27.0.dist-info}/top_level.txt +0 -0
@@ -44,6 +44,7 @@ def handle_job_result(job: Dict[str, Any]) -> Optional[Dict[str, Any]]:
44
44
  return None
45
45
 
46
46
  if not log_path or not os.path.exists(log_path):
47
+ logger.error(f"Job {job.get('id')} parse failed: log file missing or does not exist (path={log_path})")
47
48
  return None
48
49
 
49
50
  # Get engagement ID from job or fall back to current engagement
@@ -56,10 +57,12 @@ def handle_job_result(job: Dict[str, Any]) -> Optional[Dict[str, Any]]:
56
57
  engagement = em.get_current()
57
58
 
58
59
  if not engagement:
60
+ logger.error(f"Job {job.get('id')} parse failed: no engagement_id and no current engagement")
59
61
  return None
60
62
 
61
63
  engagement_id = engagement['id']
62
- except Exception:
64
+ except Exception as e:
65
+ logger.error(f"Job {job.get('id')} parse failed: engagement lookup error: {e}")
63
66
  return None
64
67
 
65
68
  # Route to appropriate parser
@@ -108,6 +111,15 @@ def handle_job_result(job: Dict[str, Any]) -> Optional[Dict[str, Any]]:
108
111
  parse_result = parse_nikto_job(engagement_id, log_path, job)
109
112
  elif tool == 'dalfox':
110
113
  parse_result = parse_dalfox_job(engagement_id, log_path, job)
114
+ elif tool == 'http_fingerprint':
115
+ parse_result = parse_http_fingerprint_job(engagement_id, log_path, job)
116
+ elif tool == 'hashcat':
117
+ parse_result = parse_hashcat_job(engagement_id, log_path, job)
118
+ elif tool == 'john':
119
+ parse_result = parse_john_job(engagement_id, log_path, job)
120
+ else:
121
+ # No parser for this tool - log it so we know
122
+ logger.warning(f"Job {job.get('id')} has no parser for tool '{tool}' - results not extracted")
111
123
 
112
124
  # NOTE: Auto-chaining is now handled in background.py after parsing completes
113
125
  # This avoids duplicate job creation and gives better control over timing
@@ -204,6 +216,8 @@ def reparse_job(job_id: int) -> Dict[str, Any]:
204
216
  parse_result = parse_nikto_job(engagement_id, log_path, job)
205
217
  elif tool == 'dalfox':
206
218
  parse_result = parse_dalfox_job(engagement_id, log_path, job)
219
+ elif tool == 'http_fingerprint':
220
+ parse_result = parse_http_fingerprint_job(engagement_id, log_path, job)
207
221
  else:
208
222
  return {'success': False, 'message': f'No parser available for tool: {tool}'}
209
223
 
@@ -3068,6 +3082,91 @@ def parse_nikto_job(engagement_id: int, log_path: str, job: Dict[str, Any]) -> D
3068
3082
  return {'error': str(e)}
3069
3083
 
3070
3084
 
3085
+ def parse_http_fingerprint_job(engagement_id: int, log_path: str, job: Dict[str, Any]) -> Dict[str, Any]:
3086
+ """
3087
+ Parse HTTP fingerprint results.
3088
+
3089
+ Returns fingerprint data for use in auto-chaining context.
3090
+ This enables downstream tools (nikto, nuclei, etc.) to make smarter decisions
3091
+ based on detected WAF, CDN, or managed hosting platform.
3092
+ """
3093
+ try:
3094
+ from souleyez.parsers.http_fingerprint_parser import (
3095
+ parse_http_fingerprint_output,
3096
+ build_fingerprint_context,
3097
+ get_tool_recommendations
3098
+ )
3099
+ from souleyez.storage.hosts import HostManager
3100
+ from urllib.parse import urlparse
3101
+
3102
+ target = job.get('target', '')
3103
+
3104
+ # Read log file
3105
+ with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
3106
+ output = f.read()
3107
+
3108
+ parsed = parse_http_fingerprint_output(output, target)
3109
+
3110
+ # Extract host from target URL
3111
+ parsed_url = urlparse(target)
3112
+ target_host = parsed_url.hostname or target
3113
+
3114
+ # Update host with fingerprint info if we have useful data
3115
+ if target_host and (parsed.get('server') or parsed.get('managed_hosting')):
3116
+ hm = HostManager()
3117
+ host_data = {
3118
+ 'ip': target_host,
3119
+ 'status': 'up'
3120
+ }
3121
+ # Store server info in notes or a dedicated field
3122
+ # For now, we just ensure the host exists
3123
+ hm.add_or_update_host(engagement_id, host_data)
3124
+
3125
+ # Build fingerprint context for chaining
3126
+ fingerprint_context = build_fingerprint_context(parsed)
3127
+
3128
+ # Get tool recommendations
3129
+ recommendations = get_tool_recommendations(parsed)
3130
+
3131
+ # Determine status
3132
+ if parsed.get('error'):
3133
+ status = STATUS_ERROR
3134
+ elif parsed.get('managed_hosting') or parsed.get('waf') or parsed.get('cdn'):
3135
+ status = STATUS_DONE # Found useful info
3136
+ elif parsed.get('server'):
3137
+ status = STATUS_DONE
3138
+ else:
3139
+ status = STATUS_NO_RESULTS
3140
+
3141
+ return {
3142
+ 'tool': 'http_fingerprint',
3143
+ 'status': status,
3144
+ 'target': target,
3145
+ 'target_host': target_host,
3146
+ # Core fingerprint data
3147
+ 'server': parsed.get('server'),
3148
+ 'managed_hosting': parsed.get('managed_hosting'),
3149
+ 'waf': parsed.get('waf', []),
3150
+ 'cdn': parsed.get('cdn', []),
3151
+ 'technologies': parsed.get('technologies', []),
3152
+ 'status_code': parsed.get('status_code'),
3153
+ # For auto-chaining context
3154
+ 'http_fingerprint': fingerprint_context.get('http_fingerprint', {}),
3155
+ 'recommendations': recommendations,
3156
+ # Pass through for downstream chains
3157
+ 'services': [{
3158
+ 'ip': target_host,
3159
+ 'port': parsed_url.port or (443 if parsed_url.scheme == 'https' else 80),
3160
+ 'service_name': 'https' if parsed_url.scheme == 'https' else 'http',
3161
+ 'product': parsed.get('server', ''),
3162
+ }],
3163
+ }
3164
+
3165
+ except Exception as e:
3166
+ logger.error(f"Error parsing http_fingerprint job: {e}")
3167
+ return {'error': str(e)}
3168
+
3169
+
3071
3170
  def parse_dalfox_job(engagement_id: int, log_path: str, job: Dict[str, Any]) -> Dict[str, Any]:
3072
3171
  """Parse Dalfox XSS scanner results."""
3073
3172
  try:
@@ -3156,3 +3255,165 @@ def parse_dalfox_job(engagement_id: int, log_path: str, job: Dict[str, Any]) ->
3156
3255
  except Exception as e:
3157
3256
  logger.error(f"Error parsing dalfox job: {e}")
3158
3257
  return {'error': str(e)}
3258
+
3259
+
3260
+ def parse_hashcat_job(engagement_id: int, log_path: str, job: Dict[str, Any]) -> Dict[str, Any]:
3261
+ """Parse hashcat job results and extract cracked passwords."""
3262
+ try:
3263
+ from souleyez.parsers.hashcat_parser import parse_hashcat_output, map_to_credentials
3264
+ from souleyez.storage.credentials import CredentialsManager
3265
+ from souleyez.storage.findings import FindingsManager
3266
+
3267
+ # Read the log file
3268
+ with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
3269
+ log_content = f.read()
3270
+
3271
+ # Parse hashcat output
3272
+ hash_file = job.get('metadata', {}).get('hash_file', '')
3273
+ parsed = parse_hashcat_output(log_content, hash_file)
3274
+
3275
+ # Store credentials
3276
+ cm = CredentialsManager()
3277
+ creds_added = 0
3278
+
3279
+ for cracked in parsed.get('cracked', []):
3280
+ try:
3281
+ cm.add_credential(
3282
+ engagement_id=engagement_id,
3283
+ host_id=None, # Hash cracking typically not tied to a specific host
3284
+ username='', # Hashcat doesn't always know the username
3285
+ password=cracked['password'],
3286
+ service='cracked_hash',
3287
+ credential_type='password',
3288
+ tool='hashcat',
3289
+ status='cracked',
3290
+ notes=f"Cracked from hash: {cracked['hash'][:32]}..."
3291
+ )
3292
+ creds_added += 1
3293
+ except Exception:
3294
+ pass # Skip duplicates
3295
+
3296
+ # Create finding if we cracked passwords
3297
+ fm = FindingsManager()
3298
+ findings_added = 0
3299
+
3300
+ if parsed.get('cracked'):
3301
+ fm.add_finding(
3302
+ engagement_id=engagement_id,
3303
+ title=f"Password Hashes Cracked - {len(parsed['cracked'])} passwords recovered",
3304
+ finding_type='credential',
3305
+ severity='high',
3306
+ description=f"Hashcat successfully cracked {len(parsed['cracked'])} password hash(es).\n\n"
3307
+ f"Status: {parsed['stats'].get('status', 'unknown')}\n"
3308
+ f"Cracked: {parsed['stats'].get('cracked_count', len(parsed['cracked']))}",
3309
+ tool='hashcat'
3310
+ )
3311
+ findings_added += 1
3312
+
3313
+ # Determine status
3314
+ if creds_added > 0:
3315
+ status = STATUS_DONE
3316
+ elif parsed['stats'].get('status') == 'exhausted':
3317
+ status = STATUS_NO_RESULTS # Ran to completion but found nothing
3318
+ else:
3319
+ status = STATUS_NO_RESULTS
3320
+
3321
+ return {
3322
+ 'tool': 'hashcat',
3323
+ 'status': status,
3324
+ 'cracked_count': len(parsed.get('cracked', [])),
3325
+ 'credentials_added': creds_added,
3326
+ 'findings_added': findings_added,
3327
+ 'hashcat_status': parsed['stats'].get('status', 'unknown')
3328
+ }
3329
+
3330
+ except Exception as e:
3331
+ logger.error(f"Error parsing hashcat job: {e}")
3332
+ return {'error': str(e)}
3333
+
3334
+
3335
+ def parse_john_job(engagement_id: int, log_path: str, job: Dict[str, Any]) -> Dict[str, Any]:
3336
+ """Parse John the Ripper job results and extract cracked passwords."""
3337
+ try:
3338
+ from souleyez.parsers.john_parser import parse_john_output
3339
+ from souleyez.storage.credentials import CredentialsManager
3340
+ from souleyez.storage.findings import FindingsManager
3341
+
3342
+ # Read the log file
3343
+ with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
3344
+ log_content = f.read()
3345
+
3346
+ # Get hash file from job metadata if available
3347
+ hash_file = job.get('metadata', {}).get('hash_file', None)
3348
+
3349
+ # Parse john output
3350
+ parsed = parse_john_output(log_content, hash_file)
3351
+
3352
+ # Store credentials
3353
+ cm = CredentialsManager()
3354
+ creds_added = 0
3355
+
3356
+ for cred in parsed.get('cracked', []):
3357
+ username = cred.get('username', '')
3358
+ password = cred.get('password', '')
3359
+
3360
+ if password: # At minimum we need a password
3361
+ try:
3362
+ cm.add_credential(
3363
+ engagement_id=engagement_id,
3364
+ host_id=None, # Hash cracking typically not tied to a specific host
3365
+ username=username if username != 'unknown' else '',
3366
+ password=password,
3367
+ service='cracked_hash',
3368
+ credential_type='password',
3369
+ tool='john',
3370
+ status='cracked',
3371
+ notes=f"Cracked by John the Ripper"
3372
+ )
3373
+ creds_added += 1
3374
+ except Exception:
3375
+ pass # Skip duplicates
3376
+
3377
+ # Create finding if we cracked passwords
3378
+ fm = FindingsManager()
3379
+ findings_added = 0
3380
+
3381
+ if parsed.get('cracked'):
3382
+ usernames = [c.get('username', 'unknown') for c in parsed['cracked'] if c.get('username')]
3383
+ usernames_str = ', '.join(usernames[:10]) # First 10
3384
+ if len(usernames) > 10:
3385
+ usernames_str += f" (+{len(usernames) - 10} more)"
3386
+
3387
+ fm.add_finding(
3388
+ engagement_id=engagement_id,
3389
+ title=f"Password Hashes Cracked - {len(parsed['cracked'])} passwords recovered",
3390
+ finding_type='credential',
3391
+ severity='high',
3392
+ description=f"John the Ripper successfully cracked {len(parsed['cracked'])} password hash(es).\n\n"
3393
+ f"Usernames: {usernames_str}\n"
3394
+ f"Session status: {parsed.get('session_status', 'unknown')}",
3395
+ tool='john'
3396
+ )
3397
+ findings_added += 1
3398
+
3399
+ # Determine status
3400
+ if creds_added > 0:
3401
+ status = STATUS_DONE
3402
+ elif parsed.get('session_status') == 'completed':
3403
+ status = STATUS_NO_RESULTS # Ran to completion but found nothing
3404
+ else:
3405
+ status = STATUS_NO_RESULTS
3406
+
3407
+ return {
3408
+ 'tool': 'john',
3409
+ 'status': status,
3410
+ 'cracked_count': len(parsed.get('cracked', [])),
3411
+ 'credentials_added': creds_added,
3412
+ 'findings_added': findings_added,
3413
+ 'session_status': parsed.get('session_status', 'unknown'),
3414
+ 'total_loaded': parsed.get('total_loaded', 0)
3415
+ }
3416
+
3417
+ except Exception as e:
3418
+ logger.error(f"Error parsing john job: {e}")
3419
+ return {'error': str(e)}
@@ -4,12 +4,13 @@ Worker health check and management utilities
4
4
  """
5
5
  import psutil
6
6
  import time
7
- from typing import Optional, Tuple
7
+ from typing import Optional, Tuple, Dict, Any
8
8
 
9
9
 
10
10
  def is_worker_running() -> Tuple[bool, Optional[int]]:
11
11
  """
12
- Check if background worker is running
12
+ Check if background worker is running.
13
+
13
14
  Returns: (is_running, pid)
14
15
  """
15
16
  for proc in psutil.process_iter(['pid', 'cmdline']):
@@ -29,6 +30,40 @@ def is_worker_running() -> Tuple[bool, Optional[int]]:
29
30
  return False, None
30
31
 
31
32
 
33
+ def is_worker_healthy() -> Tuple[bool, Optional[int], Optional[str]]:
34
+ """
35
+ Check if background worker is running AND healthy (responding).
36
+
37
+ Uses heartbeat file to verify worker is actively processing.
38
+ A worker process may exist but be frozen/hung - heartbeat detects this.
39
+
40
+ Returns: (is_healthy, pid, issue)
41
+ - is_healthy: True if worker is running and heartbeat is fresh
42
+ - pid: Worker PID if found, None otherwise
43
+ - issue: Description of issue if not healthy, None otherwise
44
+ """
45
+ from souleyez.engine.background import (
46
+ get_heartbeat_age, HEARTBEAT_STALE_THRESHOLD
47
+ )
48
+
49
+ is_running, pid = is_worker_running()
50
+
51
+ if not is_running:
52
+ return False, None, "Worker process not found"
53
+
54
+ # Check heartbeat
55
+ heartbeat_age = get_heartbeat_age()
56
+
57
+ if heartbeat_age is None:
58
+ # No heartbeat file - worker may have just started
59
+ return True, pid, "No heartbeat yet (may be starting)"
60
+
61
+ if heartbeat_age > HEARTBEAT_STALE_THRESHOLD:
62
+ return False, pid, f"Heartbeat stale ({int(heartbeat_age)}s old, threshold: {HEARTBEAT_STALE_THRESHOLD}s)"
63
+
64
+ return True, pid, None
65
+
66
+
32
67
  def start_worker_if_needed() -> bool:
33
68
  """
34
69
  Start worker if not running
@@ -107,3 +142,64 @@ def get_worker_status() -> dict:
107
142
  pass
108
143
 
109
144
  return status
145
+
146
+
147
+ def get_worker_health() -> Dict[str, Any]:
148
+ """
149
+ Get detailed worker health status including heartbeat info.
150
+
151
+ Returns dict with:
152
+ - running: Whether worker process exists
153
+ - healthy: Whether worker is running AND responsive
154
+ - pid: Worker PID if running
155
+ - uptime: Seconds since worker started
156
+ - heartbeat_age: Seconds since last heartbeat
157
+ - heartbeat_stale: Whether heartbeat is stale
158
+ - issue: Description of any health issue
159
+ - cpu_percent: CPU usage percentage
160
+ - memory_mb: Memory usage in MB
161
+ """
162
+ from souleyez.engine.background import (
163
+ get_heartbeat_age, HEARTBEAT_STALE_THRESHOLD
164
+ )
165
+
166
+ is_running, pid = is_worker_running()
167
+ heartbeat_age = get_heartbeat_age()
168
+
169
+ health = {
170
+ 'running': is_running,
171
+ 'healthy': False,
172
+ 'pid': pid,
173
+ 'uptime': None,
174
+ 'heartbeat_age': heartbeat_age,
175
+ 'heartbeat_stale': heartbeat_age is None or heartbeat_age > HEARTBEAT_STALE_THRESHOLD,
176
+ 'issue': None,
177
+ 'cpu_percent': None,
178
+ 'memory_mb': None
179
+ }
180
+
181
+ if not is_running:
182
+ health['issue'] = "Worker process not found"
183
+ return health
184
+
185
+ # Get process info
186
+ try:
187
+ proc = psutil.Process(pid)
188
+ health['uptime'] = int(time.time() - proc.create_time())
189
+ health['cpu_percent'] = proc.cpu_percent(interval=0.1)
190
+ health['memory_mb'] = round(proc.memory_info().rss / 1024 / 1024, 1)
191
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
192
+ health['issue'] = "Cannot access worker process"
193
+ return health
194
+
195
+ # Check heartbeat
196
+ if heartbeat_age is None:
197
+ health['issue'] = "No heartbeat yet (worker may be starting)"
198
+ health['healthy'] = True # Give benefit of doubt for new workers
199
+ elif heartbeat_age > HEARTBEAT_STALE_THRESHOLD:
200
+ health['issue'] = f"Worker unresponsive (heartbeat {int(heartbeat_age)}s old)"
201
+ health['healthy'] = False
202
+ else:
203
+ health['healthy'] = True
204
+
205
+ return health
souleyez/main.py CHANGED
@@ -173,7 +173,7 @@ def _check_privileged_tools():
173
173
 
174
174
 
175
175
  @click.group()
176
- @click.version_option(version='2.22.0')
176
+ @click.version_option(version='2.27.0')
177
177
  def cli():
178
178
  """SoulEyez - AI-Powered Pentesting Platform by CyberSoul Security"""
179
179
  from souleyez.log_config import init_logging
@@ -1388,19 +1388,24 @@ def _run_doctor(fix=False, verbose=False):
1388
1388
  path_dirs = os.environ.get('PATH', '').split(':')
1389
1389
  pipx_bin = str(Path.home() / '.local' / 'bin')
1390
1390
  go_bin = str(Path.home() / 'go' / 'bin')
1391
+
1392
+ # Detect shell config file (zsh for Kali, bash for others)
1393
+ shell = os.environ.get('SHELL', '/bin/bash')
1394
+ shell_rc = '~/.zshrc' if 'zsh' in shell else '~/.bashrc'
1395
+
1391
1396
  if pipx_bin in path_dirs:
1392
1397
  if verbose:
1393
1398
  check_pass("PATH includes ~/.local/bin (pipx)")
1394
1399
  else:
1395
1400
  if Path(pipx_bin).exists() and any(Path(pipx_bin).iterdir()):
1396
- check_warn("~/.local/bin not in PATH", "Add to ~/.bashrc: export PATH=\"$HOME/.local/bin:$PATH\"")
1401
+ check_warn("~/.local/bin not in PATH", f"Add to {shell_rc}: export PATH=\"$HOME/.local/bin:$PATH\"")
1397
1402
 
1398
1403
  if go_bin in path_dirs:
1399
1404
  if verbose:
1400
1405
  check_pass("PATH includes ~/go/bin")
1401
1406
  else:
1402
1407
  if Path(go_bin).exists() and any(Path(go_bin).iterdir()):
1403
- check_warn("~/go/bin not in PATH", "Add to ~/.bashrc: export PATH=\"$HOME/go/bin:$PATH\"")
1408
+ check_warn("~/go/bin not in PATH", f"Add to {shell_rc}: export PATH=\"$HOME/go/bin:$PATH\"")
1404
1409
 
1405
1410
  # Check database is writable
1406
1411
  if db_path.exists():
@@ -1430,8 +1435,10 @@ def _run_doctor(fix=False, verbose=False):
1430
1435
  # Section 7: MSF Database (if msfconsole available)
1431
1436
  if shutil.which('msfconsole'):
1432
1437
  click.echo(click.style("Metasploit", bold=True))
1438
+ # Check user config first, then system-wide config (Kali uses system-wide)
1433
1439
  msf_db = Path.home() / '.msf4' / 'database.yml'
1434
- if msf_db.exists():
1440
+ system_msf_db = Path('/usr/share/metasploit-framework/config/database.yml')
1441
+ if msf_db.exists() or system_msf_db.exists():
1435
1442
  check_pass("MSF database configured")
1436
1443
  else:
1437
1444
  check_fail("MSF database not initialized", "msfdb init")
@@ -1513,6 +1520,98 @@ def tutorial():
1513
1520
  run_tutorial()
1514
1521
 
1515
1522
 
1523
+ @cli.command('install-desktop')
1524
+ @click.option('--remove', is_flag=True, help='Remove the desktop shortcut')
1525
+ def install_desktop(remove):
1526
+ """Install SoulEyez desktop shortcut in Applications menu.
1527
+
1528
+ Creates a .desktop file so SoulEyez appears in your
1529
+ Applications > Security menu with its icon.
1530
+ """
1531
+ import shutil
1532
+ from importlib import resources
1533
+
1534
+ applications_dir = Path.home() / '.local' / 'share' / 'applications'
1535
+ icons_dir = Path.home() / '.local' / 'share' / 'icons'
1536
+ desktop_file = applications_dir / 'souleyez.desktop'
1537
+ icon_dest = icons_dir / 'souleyez.png'
1538
+
1539
+ if remove:
1540
+ # Remove desktop shortcut
1541
+ removed = False
1542
+ if desktop_file.exists():
1543
+ desktop_file.unlink()
1544
+ click.echo(click.style(" Removed desktop shortcut", fg='green'))
1545
+ removed = True
1546
+ if icon_dest.exists():
1547
+ icon_dest.unlink()
1548
+ click.echo(click.style(" Removed icon", fg='green'))
1549
+ removed = True
1550
+ if removed:
1551
+ click.echo(click.style("\nSoulEyez removed from Applications menu.", fg='cyan'))
1552
+ else:
1553
+ click.echo(click.style("No desktop shortcut found.", fg='yellow'))
1554
+ return
1555
+
1556
+ click.echo(click.style("\nInstalling SoulEyez desktop shortcut...\n", fg='cyan', bold=True))
1557
+
1558
+ # Create directories
1559
+ applications_dir.mkdir(parents=True, exist_ok=True)
1560
+ icons_dir.mkdir(parents=True, exist_ok=True)
1561
+
1562
+ # Find and copy icon
1563
+ try:
1564
+ # Try importlib.resources first (Python 3.9+)
1565
+ try:
1566
+ from importlib.resources import files
1567
+ icon_source = files('souleyez.assets').joinpath('souleyez-icon.png')
1568
+ with open(icon_source, 'rb') as src:
1569
+ icon_data = src.read()
1570
+ except (ImportError, TypeError, FileNotFoundError):
1571
+ # Fallback: find icon relative to this file
1572
+ icon_source = Path(__file__).parent / 'assets' / 'souleyez-icon.png'
1573
+ with open(icon_source, 'rb') as src:
1574
+ icon_data = src.read()
1575
+
1576
+ with open(icon_dest, 'wb') as dst:
1577
+ dst.write(icon_data)
1578
+ click.echo(click.style(" Installed icon", fg='green'))
1579
+ except Exception as e:
1580
+ click.echo(click.style(f" Warning: Could not copy icon: {e}", fg='yellow'))
1581
+ icon_dest = "utilities-terminal" # Fallback to system icon
1582
+
1583
+ # Create .desktop file
1584
+ desktop_content = f"""[Desktop Entry]
1585
+ Name=SoulEyez
1586
+ Comment=AI-Powered Penetration Testing Platform
1587
+ Exec=souleyez interactive
1588
+ Icon={icon_dest}
1589
+ Terminal=true
1590
+ Type=Application
1591
+ Categories=Security;System;Network;
1592
+ Keywords=pentest;security;hacking;nmap;metasploit;
1593
+ """
1594
+
1595
+ desktop_file.write_text(desktop_content)
1596
+ click.echo(click.style(" Created desktop entry", fg='green'))
1597
+
1598
+ # Update desktop database (optional, may not be available)
1599
+ try:
1600
+ import subprocess
1601
+ subprocess.run(['update-desktop-database', str(applications_dir)],
1602
+ capture_output=True, check=False)
1603
+ except Exception:
1604
+ pass # Not critical if this fails
1605
+
1606
+ click.echo()
1607
+ click.echo(click.style("SoulEyez added to Applications menu!", fg='green', bold=True))
1608
+ click.echo()
1609
+ click.echo("You can find it under:")
1610
+ click.echo(click.style(" Applications > Security > SoulEyez", fg='cyan'))
1611
+ click.echo()
1612
+ click.echo("To remove: souleyez install-desktop --remove")
1613
+
1614
+
1516
1615
  def main():
1517
1616
  """Main entry point."""
1518
1617
  cli()