praetorian-cli 2.2.1__py3-none-any.whl → 2.2.3__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 (43) hide show
  1. praetorian_cli/handlers/add.py +25 -7
  2. praetorian_cli/handlers/aegis.py +107 -0
  3. praetorian_cli/handlers/delete.py +3 -2
  4. praetorian_cli/handlers/get.py +48 -2
  5. praetorian_cli/handlers/list.py +41 -9
  6. praetorian_cli/handlers/ssh_utils.py +154 -0
  7. praetorian_cli/handlers/test.py +7 -2
  8. praetorian_cli/handlers/update.py +3 -3
  9. praetorian_cli/main.py +1 -0
  10. praetorian_cli/sdk/chariot.py +71 -12
  11. praetorian_cli/sdk/entities/aegis.py +437 -0
  12. praetorian_cli/sdk/entities/assets.py +30 -12
  13. praetorian_cli/sdk/entities/scanners.py +13 -0
  14. praetorian_cli/sdk/entities/schema.py +27 -0
  15. praetorian_cli/sdk/entities/seeds.py +108 -56
  16. praetorian_cli/sdk/mcp_server.py +2 -3
  17. praetorian_cli/sdk/model/aegis.py +156 -0
  18. praetorian_cli/sdk/model/query.py +1 -1
  19. praetorian_cli/sdk/model/utils.py +2 -8
  20. praetorian_cli/sdk/test/pytest.ini +1 -0
  21. praetorian_cli/sdk/test/test_asset.py +2 -2
  22. praetorian_cli/sdk/test/test_seed.py +13 -14
  23. praetorian_cli/sdk/test/test_z_cli.py +22 -24
  24. praetorian_cli/sdk/test/ui_mocks.py +133 -0
  25. praetorian_cli/sdk/test/utils.py +16 -4
  26. praetorian_cli/ui/__init__.py +3 -0
  27. praetorian_cli/ui/aegis/__init__.py +5 -0
  28. praetorian_cli/ui/aegis/commands/__init__.py +2 -0
  29. praetorian_cli/ui/aegis/commands/help.py +81 -0
  30. praetorian_cli/ui/aegis/commands/info.py +136 -0
  31. praetorian_cli/ui/aegis/commands/job.py +381 -0
  32. praetorian_cli/ui/aegis/commands/list.py +14 -0
  33. praetorian_cli/ui/aegis/commands/set.py +32 -0
  34. praetorian_cli/ui/aegis/commands/ssh.py +87 -0
  35. praetorian_cli/ui/aegis/constants.py +20 -0
  36. praetorian_cli/ui/aegis/menu.py +395 -0
  37. praetorian_cli/ui/aegis/utils.py +162 -0
  38. {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/METADATA +4 -1
  39. {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/RECORD +43 -24
  40. {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/WHEEL +0 -0
  41. {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/entry_points.txt +0 -0
  42. {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/licenses/LICENSE +0 -0
  43. {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,381 @@
1
+ import json
2
+ from rich.table import Table
3
+ from rich.box import MINIMAL
4
+ from rich.prompt import Prompt, Confirm
5
+ from ..utils import format_timestamp, format_job_status
6
+ from ..constants import DEFAULT_COLORS
7
+
8
+
9
+ def handle_job(menu, args):
10
+ """Handle job command with subcommands: list, run, capabilities."""
11
+ if not args:
12
+ show_job_help(menu)
13
+ return
14
+
15
+ subcommand = args[0].lower()
16
+ if subcommand == 'list':
17
+ list_jobs(menu)
18
+ elif subcommand == 'run':
19
+ run_job(menu, args[1:])
20
+ elif subcommand in ['capabilities', 'caps']:
21
+ list_capabilities(menu, args[1:])
22
+ else:
23
+ menu.console.print(f"\n Unknown job subcommand: {subcommand}")
24
+ show_job_help(menu)
25
+
26
+
27
+ def show_job_help(menu):
28
+ help_text = f"""
29
+ Job Commands
30
+
31
+ job list List recent jobs for selected agent
32
+ job run [capability] Run a capability on selected agent (interactive picker)
33
+ job capabilities List available capabilities (alias: caps)
34
+ [--details] Show full descriptions
35
+
36
+ Examples:
37
+ job list # List recent jobs
38
+ job capabilities # List capabilities with brief descriptions
39
+ job caps --details # List capabilities with full descriptions
40
+ job run # Interactive capability picker
41
+ job run windows-enum # Run specific capability with confirmation
42
+ """
43
+ menu.console.print(help_text)
44
+ menu.pause()
45
+
46
+
47
+ def list_jobs(menu):
48
+ if not menu.selected_agent:
49
+ menu.console.print("\n No agent selected. Use 'set <id>' to select one.\n")
50
+ menu.pause()
51
+ return
52
+
53
+ hostname = menu.selected_agent.hostname
54
+
55
+ try:
56
+ jobs, _ = menu.sdk.jobs.list(prefix_filter=hostname)
57
+
58
+ if not jobs:
59
+ menu.console.print(f"\n No jobs found for {hostname}\n")
60
+ menu.pause()
61
+ return
62
+
63
+ jobs.sort(key=lambda j: j.get('created', 0), reverse=True)
64
+
65
+ colors = getattr(menu, 'colors', DEFAULT_COLORS)
66
+ jobs_table = Table(
67
+ show_header=True,
68
+ header_style=f"bold {colors['primary']}",
69
+ border_style=colors['dim'],
70
+ box=MINIMAL,
71
+ show_lines=False,
72
+ padding=(0, 2),
73
+ pad_edge=False
74
+ )
75
+
76
+ jobs_table.add_column("JOB ID", style=f"bold {colors['accent']}", width=12, no_wrap=True)
77
+ jobs_table.add_column("CAPABILITY", style="white", min_width=20, no_wrap=True)
78
+ jobs_table.add_column("STATUS", width=10, justify="center", no_wrap=True)
79
+ jobs_table.add_column("CREATED", style=f"{colors['dim']}", width=12, justify="right", no_wrap=True)
80
+
81
+ menu.console.print()
82
+ menu.console.print(f" Recent Jobs for {hostname}")
83
+ menu.console.print()
84
+
85
+ for job in jobs[:10]:
86
+ capability = job.get('capabilities', ['unknown'])[0] if job.get('capabilities') else 'unknown'
87
+ status = job.get('status', 'unknown')
88
+ job_id = job.get('key', '').split('#')[-1][:10]
89
+ created = job.get('created', 0)
90
+
91
+ created_str = format_timestamp(created)
92
+ status_display = format_job_status(status, colors)
93
+
94
+ jobs_table.add_row(job_id, capability, status_display, created_str)
95
+
96
+ menu.console.print(jobs_table)
97
+ menu.console.print()
98
+ menu.pause()
99
+
100
+ except Exception as e:
101
+ menu.console.print(f"[red]Error listing jobs: {e}[/red]")
102
+ menu.pause()
103
+
104
+
105
+ def run_job(menu, args):
106
+ if not menu.selected_agent:
107
+ menu.console.print("\n No agent selected. Use 'set <id>' to select one.\n")
108
+ menu.pause()
109
+ return
110
+
111
+ hostname = menu.selected_agent.hostname or 'Unknown'
112
+
113
+ # Use interactive capability picker if no capability provided, otherwise validate the provided one
114
+ suggested_capability = args[0] if args else None
115
+ capability = _interactive_capability_picker(menu, suggested_capability)
116
+ if not capability:
117
+ return # User cancelled
118
+
119
+ # Validate capability using SDK
120
+ capability_info = menu.sdk.aegis.validate_capability(capability)
121
+ if not capability_info:
122
+ colors = getattr(menu, 'colors', DEFAULT_COLORS)
123
+ menu.console.print(f" [{colors['error']}]Invalid capability: '{capability}'[/{colors['error']}]")
124
+ menu.console.print(" Use 'job capabilities' to see available options")
125
+ menu.pause()
126
+ return
127
+
128
+ target_type = capability_info.get('target', 'asset').lower()
129
+
130
+ # Create appropriate target key
131
+ if target_type == 'addomain':
132
+ # For AD capabilities, use interactive domain selection
133
+ domain = _select_domain(menu)
134
+ if not domain:
135
+ return # User cancelled
136
+ target_key = f"#addomain#{domain}#{domain}"
137
+ target_display = f"domain {domain}"
138
+ else:
139
+ target_key = f"#asset#{hostname}#{hostname}"
140
+ target_display = f"asset {hostname}"
141
+
142
+ # Handle credentials for capabilities that need them
143
+ credentials = None
144
+ if any(keyword in capability.lower() for keyword in ['ad-', 'smb-', 'domain-', 'ldap', 'winrm']):
145
+ if Confirm.ask(" This capability may require credentials. Add them?"):
146
+ username = Prompt.ask(" Username")
147
+ password = Prompt.ask(" Password", password=True)
148
+ credentials = {"Username": username, "Password": password}
149
+
150
+ # Create job configuration using SDK
151
+ config = menu.sdk.aegis.create_job_config(menu.selected_agent, credentials)
152
+
153
+ # Confirm job execution
154
+ if not Confirm.ask(f"\n Run '{capability}' on {target_display}?"):
155
+ menu.console.print(" Cancelled\n")
156
+ menu.pause()
157
+ return
158
+
159
+ try:
160
+ config_json = json.dumps(config)
161
+
162
+ # Add job using SDK
163
+ jobs = menu.sdk.jobs.add(target_key, [capability], config_json)
164
+
165
+ if jobs:
166
+ job = jobs[0] if isinstance(jobs, list) else jobs
167
+ job_key = job.get('key', '')
168
+ status = job.get('status', 'unknown')
169
+ job_id = job_key.split('#')[-1][:12] if job_key else 'unknown'
170
+
171
+ menu.console.print(f"\n[green]✓ Job {job_id} queued successfully[/green]")
172
+ menu.console.print(f" Capability: {capability}")
173
+ menu.console.print(f" Target: {target_display}")
174
+ menu.console.print(f" Status: {status}")
175
+ else:
176
+ menu.console.print("\n[red]Error: No job returned from API[/red]")
177
+
178
+ except Exception as e:
179
+ menu.console.print(f"\n[red]Job execution error: {e}[/red]")
180
+
181
+ menu.console.print()
182
+ menu.pause()
183
+
184
+
185
+ def list_capabilities(menu, args):
186
+ if not menu.selected_agent:
187
+ menu.console.print("\n No agent selected. Use 'set <id>' to select one.\n")
188
+ menu.pause()
189
+ return
190
+
191
+ show_details = '--details' in args or '-d' in args
192
+
193
+ try:
194
+ result = menu.sdk.aegis.run_job(
195
+ agent=menu.selected_agent,
196
+ capabilities=None,
197
+ config=None
198
+ )
199
+
200
+ colors = getattr(menu, 'colors', DEFAULT_COLORS)
201
+ if 'capabilities' in result:
202
+ capabilities_table = Table(
203
+ show_header=True,
204
+ header_style=f"bold {colors['primary']}",
205
+ border_style=colors['dim'],
206
+ box=MINIMAL,
207
+ show_lines=False,
208
+ padding=(0, 2),
209
+ pad_edge=False
210
+ )
211
+
212
+ capabilities_table.add_column("CAPABILITY", style=f"bold {colors['success']}", min_width=25, no_wrap=True)
213
+ capabilities_table.add_column("DESCRIPTION", style="white", no_wrap=False)
214
+
215
+ for cap in result['capabilities']:
216
+ name = cap.get('name', 'unknown')
217
+ full_desc = cap.get('description', '') or 'No description available'
218
+ if show_details:
219
+ desc = full_desc
220
+ else:
221
+ desc = full_desc[:80] + '...' if len(full_desc) > 80 else full_desc
222
+ capabilities_table.add_row(name, desc)
223
+
224
+ menu.console.print()
225
+ title = " Available Capabilities"
226
+ title += " (Detailed)" if show_details else " (use --details for full descriptions)"
227
+ menu.console.print(title)
228
+ menu.console.print()
229
+ menu.console.print(capabilities_table)
230
+ else:
231
+ menu.console.print(f"[yellow] No capabilities available for this agent[/yellow]")
232
+
233
+ menu.console.print()
234
+ menu.pause()
235
+ except Exception as e:
236
+ menu.console.print(f"[red]Error listing capabilities: {e}[/red]")
237
+ menu.pause()
238
+
239
+
240
+ def _interactive_capability_picker(menu, suggested=None):
241
+ """Interactive capability picker with numbered options"""
242
+ colors = getattr(menu, 'colors', DEFAULT_COLORS)
243
+
244
+ if suggested:
245
+ # Validate the suggested capability first
246
+ capability_info = menu.sdk.aegis.validate_capability(suggested)
247
+ if capability_info:
248
+ # Show the suggested capability and ask for confirmation
249
+ desc = (capability_info.get('description', '') or '')[:60]
250
+ menu.console.print(f"\n Suggested capability:")
251
+ menu.console.print(f" {suggested}")
252
+ menu.console.print(f" [{colors['dim']}]{desc}[/{colors['dim']}]")
253
+
254
+ if Confirm.ask(" Use this capability?", default=True):
255
+ return suggested
256
+ # If they decline, continue to show the full picker below
257
+
258
+ try:
259
+ # Determine agent OS for filtering
260
+ agent_os = _detect_agent_os(menu)
261
+
262
+ # Get capabilities filtered by OS
263
+ caps = menu.sdk.aegis.get_capabilities(surface_filter='internal', agent_os=agent_os)
264
+
265
+ if not caps:
266
+ # Fallback to all capabilities
267
+ caps = menu.sdk.aegis.get_capabilities(surface_filter='internal')
268
+
269
+ if not caps:
270
+ menu.console.print(" No capabilities available.")
271
+ return None
272
+
273
+ if agent_os:
274
+ menu.console.print(f" [{colors['dim']}]Showing {agent_os.title()} capabilities[/{colors['dim']}]")
275
+
276
+ # Sort and display with numbers
277
+ caps.sort(key=lambda x: x.get('name', ''))
278
+
279
+ menu.console.print(f"\n Select capability:")
280
+ for i, cap in enumerate(caps[:20], 1): # Limit to 20 for usability
281
+ name = cap.get('name', 'unknown')
282
+ desc = (cap.get('description', '') or '')[:40]
283
+ menu.console.print(f" {i:2d}. {name:<25} {desc}")
284
+
285
+ if len(caps) > 20:
286
+ menu.console.print(f" [{colors['dim']}]... and {len(caps) - 20} more capabilities[/{colors['dim']}]")
287
+
288
+ menu.console.print(f" 0. Enter capability name manually")
289
+
290
+ while True:
291
+ try:
292
+ choice = Prompt.ask(" Choice", default="1")
293
+ choice_num = int(choice.strip())
294
+
295
+ if choice_num == 0:
296
+ return Prompt.ask(" Enter capability name")
297
+ elif 1 <= choice_num <= min(len(caps), 20):
298
+ return caps[choice_num - 1].get('name', '')
299
+ else:
300
+ menu.console.print(f" Please enter a number between 0 and {min(len(caps), 20)}")
301
+
302
+ except ValueError:
303
+ menu.console.print(" Please enter a valid number")
304
+ except KeyboardInterrupt:
305
+ menu.console.print(" Cancelled")
306
+ return None
307
+
308
+ except Exception as e:
309
+ menu.console.print(f" Error loading capabilities: {e}")
310
+ return Prompt.ask(" Enter capability name manually")
311
+
312
+
313
+ def _detect_agent_os(menu):
314
+ """Detect the operating system of the selected agent"""
315
+ if not menu.selected_agent:
316
+ return None
317
+
318
+ os_field = (menu.selected_agent.os or '').lower()
319
+
320
+ if os_field:
321
+ if 'linux' in os_field or os_field in ['ubuntu', 'centos', 'debian', 'rhel', 'fedora', 'suse']:
322
+ return 'linux'
323
+ elif 'windows' in os_field or os_field in ['win32', 'win64', 'nt']:
324
+ return 'windows'
325
+
326
+ return None
327
+
328
+
329
+ def _select_domain(menu):
330
+ """Interactive domain selection"""
331
+ colors = getattr(menu, 'colors', DEFAULT_COLORS)
332
+
333
+ try:
334
+ menu.console.print(f" [{colors['dim']}]Looking for available domains...[/{colors['dim']}]")
335
+
336
+ # Use SDK to get available AD domains
337
+ domains = menu.sdk.aegis.get_available_ad_domains()
338
+
339
+ menu.console.print(f" [{colors['dim']}]Found {len(domains)} domains[/{colors['dim']}]")
340
+
341
+ if domains:
342
+ menu.console.print(f"\n Available domains:")
343
+ for i, domain in enumerate(domains[:10], 1): # Limit to 10
344
+ menu.console.print(f" {i:2d}. {domain}")
345
+
346
+ if len(domains) > 10:
347
+ menu.console.print(f" [{colors['dim']}]... and {len(domains) - 10} more[/{colors['dim']}]")
348
+
349
+ menu.console.print(f" 0. Enter domain manually")
350
+
351
+ while True:
352
+ try:
353
+ choice = Prompt.ask(" Choose domain", default="1")
354
+ choice_num = int(choice.strip())
355
+
356
+ if choice_num == 0:
357
+ return Prompt.ask(" Enter domain name")
358
+ elif 1 <= choice_num <= min(len(domains), 10):
359
+ return domains[choice_num - 1]
360
+ else:
361
+ menu.console.print(f" Please enter a number between 0 and {min(len(domains), 10)}")
362
+
363
+ except ValueError:
364
+ menu.console.print(" Please enter a valid number")
365
+ except KeyboardInterrupt:
366
+ return None
367
+ else:
368
+ menu.console.print(f" [{colors['dim']}]No domains found in assets. You can still enter one manually.[/{colors['dim']}]")
369
+ return Prompt.ask(" Enter domain name (e.g., contoso.com, example.local)")
370
+
371
+ except Exception as e:
372
+ menu.console.print(f" [{colors['dim']}]Error during domain selection: {e}[/{colors['dim']}]")
373
+ return Prompt.ask(" Enter domain name (e.g., contoso.com, example.local)")
374
+
375
+
376
+ def complete(menu, text, tokens):
377
+ sub = ['list', 'run', 'capabilities', 'caps']
378
+ if len(tokens) <= 2:
379
+ return [s for s in sub if s.startswith(text)]
380
+ # Could extend to capability names later
381
+ return []
@@ -0,0 +1,14 @@
1
+ def handle_list(menu, args):
2
+ """List agents with optional offline flag."""
3
+ show_offline = '--all' in args or '-a' in args
4
+
5
+ if not menu.agents:
6
+ menu.load_agents()
7
+
8
+ menu.show_agents_list(show_offline=show_offline)
9
+ menu.pause()
10
+
11
+
12
+ def complete(menu, text, tokens):
13
+ return [o for o in ['--all', '-a'] if o.startswith(text)]
14
+
@@ -0,0 +1,32 @@
1
+ from ..utils import parse_agent_identifier
2
+
3
+
4
+ def handle_set(menu, args):
5
+ """Select an agent by index, client_id, or hostname."""
6
+ if not args:
7
+ menu.console.print("\n No agent selected. Use 'set <id>' to select one.\n")
8
+ menu.pause()
9
+ return
10
+
11
+ selection = args[0]
12
+ selected_agent = parse_agent_identifier(selection, menu.agents)
13
+
14
+ if selected_agent:
15
+ menu.selected_agent = selected_agent
16
+ hostname = selected_agent.hostname
17
+ menu.console.print(f"\n Selected: {hostname}\n")
18
+ else:
19
+ menu.console.print(f"\n[red] Agent not found:[/red] {selection}")
20
+ menu.console.print(f"[dim] Use agent number (1-{len(menu.agents)}), client ID, or hostname[/dim]\n")
21
+ menu.pause()
22
+
23
+
24
+ def complete(menu, text, tokens):
25
+ suggestions = []
26
+ for idx, agent in enumerate(menu.agents, 1):
27
+ suggestions.append(str(idx))
28
+ if agent.hostname:
29
+ suggestions.append(agent.hostname)
30
+ if agent.client_id:
31
+ suggestions.append(agent.client_id)
32
+ return [s for s in suggestions if s.startswith(text)]
@@ -0,0 +1,87 @@
1
+ from praetorian_cli.handlers.ssh_utils import SSHArgumentParser
2
+
3
+ def _print_help(menu):
4
+ help_text = """
5
+ SSH Command
6
+
7
+ Pass native ssh flags directly; we tunnel the host.
8
+ Username: use '-u/--user <user>' or native '-l <user>'.
9
+
10
+ Common options (forwarded to ssh):
11
+ -L [bind_address:]port:host:hostport Local port forward (repeatable)
12
+ -R [bind_address:]port:host:hostport Remote port forward (repeatable)
13
+ -D [bind_address:]port Dynamic SOCKS proxy
14
+ -i IDENTITY_FILE Identity (private key) file
15
+ -l USER Remote username (alternative to -u/--user)
16
+ -o OPTION=VALUE Extra ssh config option
17
+ -p PORT SSH port
18
+ -v/-vv/-vvv Verbose output
19
+
20
+ Examples:
21
+ ssh -L 8080:localhost:80 -D 1080 -i ~/.ssh/id_ed25519
22
+ ssh -l admin -o StrictHostKeyChecking=no
23
+ """
24
+ menu.console.print(f"\n{help_text}")
25
+
26
+
27
+ def handle_ssh(menu, args):
28
+ """SSH into the selected agent with optional port forwarding."""
29
+ if not menu.selected_agent:
30
+ menu.console.print("\n No agent selected. Use 'set <id>' to select one.\n")
31
+ menu.pause()
32
+ return
33
+
34
+ # Accept `ssh help` too
35
+ if len(args) and args[0].lower() == 'help':
36
+ _print_help(menu)
37
+ menu.pause()
38
+ return
39
+
40
+ # Also support '-h/--help'
41
+ if any(a in ('-h', '--help') for a in args):
42
+ _print_help(menu)
43
+ menu.pause()
44
+ return
45
+
46
+ parser = SSHArgumentParser(console=menu.console)
47
+
48
+ # Validate agent first
49
+ if not parser.validate_agent_ssh_availability(menu.selected_agent):
50
+ menu.pause()
51
+ return
52
+
53
+ # Parse arguments using shared parser
54
+ parsed_options = parser.parse_ssh_args(args)
55
+ if not parsed_options:
56
+ # Error already displayed by parser
57
+ menu.pause()
58
+ return
59
+
60
+ # Build options list from parsed options for SDK
61
+ options = parsed_options.get('passthrough', [])
62
+
63
+ # Add structured options back as SSH flags
64
+ for forward in parsed_options.get('local_forward', []):
65
+ options.extend(['-L', forward])
66
+ for forward in parsed_options.get('remote_forward', []):
67
+ options.extend(['-R', forward])
68
+ if parsed_options.get('dynamic_forward'):
69
+ options.extend(['-D', parsed_options['dynamic_forward']])
70
+ if parsed_options.get('key'):
71
+ options.extend(['-i', parsed_options['key']])
72
+
73
+ try:
74
+ menu.sdk.aegis.ssh_to_agent(
75
+ agent=menu.selected_agent,
76
+ options=options,
77
+ user=parsed_options.get('user'),
78
+ display_info=True
79
+ )
80
+ except Exception as e:
81
+ menu.console.print(f"[red]SSH error: {e}[/red]")
82
+ menu.pause()
83
+
84
+
85
+ def complete(menu, text, tokens):
86
+ opts = ['help', '-h', '--help', '-u', '--user', '-l', '-i', '-L', '-R', '-D', '-o', '-F', '-p', '-v', '-vv', '-vvv', '-4', '-6', '-A', '-a', '-C', '-N', '-T', '-q']
87
+ return [o for o in opts if o.startswith(text)]
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Constants for the Aegis UI layer
4
+ """
5
+
6
+ DEFAULT_COLORS = {
7
+ 'primary': '#5F47B7', # Primary purple
8
+ 'secondary': '#8F7ECD', # Secondary purple
9
+ 'accent': '#BFB5E2', # Tertiary purple
10
+ 'dark': '#0D0D28', # Dark primary
11
+ 'dark_sec': '#191933', # Dark secondary
12
+ 'success': '#4CAF50', # Green
13
+ 'error': '#F44336', # Red
14
+ 'warning': '#FFC107', # Yellow
15
+ 'info': '#2196F3', # Blue
16
+ 'text': '#FFFFFF', # White text
17
+ 'dim': '#B6B6BE' # Light secondary
18
+ }
19
+
20
+