hackagent 0.3.1__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 (183) hide show
  1. hackagent/__init__.py +12 -0
  2. hackagent/agent.py +214 -0
  3. hackagent/api/__init__.py +1 -0
  4. hackagent/api/agent/__init__.py +1 -0
  5. hackagent/api/agent/agent_create.py +347 -0
  6. hackagent/api/agent/agent_destroy.py +140 -0
  7. hackagent/api/agent/agent_list.py +242 -0
  8. hackagent/api/agent/agent_partial_update.py +361 -0
  9. hackagent/api/agent/agent_retrieve.py +235 -0
  10. hackagent/api/agent/agent_update.py +361 -0
  11. hackagent/api/apilogs/__init__.py +1 -0
  12. hackagent/api/apilogs/apilogs_list.py +170 -0
  13. hackagent/api/apilogs/apilogs_retrieve.py +162 -0
  14. hackagent/api/attack/__init__.py +1 -0
  15. hackagent/api/attack/attack_create.py +275 -0
  16. hackagent/api/attack/attack_destroy.py +146 -0
  17. hackagent/api/attack/attack_list.py +254 -0
  18. hackagent/api/attack/attack_partial_update.py +289 -0
  19. hackagent/api/attack/attack_retrieve.py +247 -0
  20. hackagent/api/attack/attack_update.py +289 -0
  21. hackagent/api/checkout/__init__.py +1 -0
  22. hackagent/api/checkout/checkout_create.py +225 -0
  23. hackagent/api/generate/__init__.py +1 -0
  24. hackagent/api/generate/generate_create.py +253 -0
  25. hackagent/api/judge/__init__.py +1 -0
  26. hackagent/api/judge/judge_create.py +253 -0
  27. hackagent/api/key/__init__.py +1 -0
  28. hackagent/api/key/key_create.py +179 -0
  29. hackagent/api/key/key_destroy.py +103 -0
  30. hackagent/api/key/key_list.py +170 -0
  31. hackagent/api/key/key_retrieve.py +162 -0
  32. hackagent/api/organization/__init__.py +1 -0
  33. hackagent/api/organization/organization_create.py +208 -0
  34. hackagent/api/organization/organization_destroy.py +104 -0
  35. hackagent/api/organization/organization_list.py +170 -0
  36. hackagent/api/organization/organization_me_retrieve.py +126 -0
  37. hackagent/api/organization/organization_partial_update.py +222 -0
  38. hackagent/api/organization/organization_retrieve.py +163 -0
  39. hackagent/api/organization/organization_update.py +222 -0
  40. hackagent/api/prompt/__init__.py +1 -0
  41. hackagent/api/prompt/prompt_create.py +171 -0
  42. hackagent/api/prompt/prompt_destroy.py +104 -0
  43. hackagent/api/prompt/prompt_list.py +185 -0
  44. hackagent/api/prompt/prompt_partial_update.py +185 -0
  45. hackagent/api/prompt/prompt_retrieve.py +163 -0
  46. hackagent/api/prompt/prompt_update.py +185 -0
  47. hackagent/api/result/__init__.py +1 -0
  48. hackagent/api/result/result_create.py +175 -0
  49. hackagent/api/result/result_destroy.py +106 -0
  50. hackagent/api/result/result_list.py +249 -0
  51. hackagent/api/result/result_partial_update.py +193 -0
  52. hackagent/api/result/result_retrieve.py +167 -0
  53. hackagent/api/result/result_trace_create.py +177 -0
  54. hackagent/api/result/result_update.py +189 -0
  55. hackagent/api/run/__init__.py +1 -0
  56. hackagent/api/run/run_create.py +187 -0
  57. hackagent/api/run/run_destroy.py +112 -0
  58. hackagent/api/run/run_list.py +291 -0
  59. hackagent/api/run/run_partial_update.py +201 -0
  60. hackagent/api/run/run_result_create.py +177 -0
  61. hackagent/api/run/run_retrieve.py +179 -0
  62. hackagent/api/run/run_run_tests_create.py +187 -0
  63. hackagent/api/run/run_update.py +201 -0
  64. hackagent/api/user/__init__.py +1 -0
  65. hackagent/api/user/user_create.py +212 -0
  66. hackagent/api/user/user_destroy.py +106 -0
  67. hackagent/api/user/user_list.py +174 -0
  68. hackagent/api/user/user_me_retrieve.py +126 -0
  69. hackagent/api/user/user_me_update.py +196 -0
  70. hackagent/api/user/user_partial_update.py +226 -0
  71. hackagent/api/user/user_retrieve.py +167 -0
  72. hackagent/api/user/user_update.py +226 -0
  73. hackagent/attacks/AdvPrefix/__init__.py +41 -0
  74. hackagent/attacks/AdvPrefix/completions.py +416 -0
  75. hackagent/attacks/AdvPrefix/config.py +259 -0
  76. hackagent/attacks/AdvPrefix/evaluation.py +745 -0
  77. hackagent/attacks/AdvPrefix/evaluators.py +564 -0
  78. hackagent/attacks/AdvPrefix/generate.py +711 -0
  79. hackagent/attacks/AdvPrefix/utils.py +307 -0
  80. hackagent/attacks/__init__.py +35 -0
  81. hackagent/attacks/advprefix.py +507 -0
  82. hackagent/attacks/base.py +106 -0
  83. hackagent/attacks/strategies.py +906 -0
  84. hackagent/cli/__init__.py +19 -0
  85. hackagent/cli/commands/__init__.py +20 -0
  86. hackagent/cli/commands/agent.py +100 -0
  87. hackagent/cli/commands/attack.py +417 -0
  88. hackagent/cli/commands/config.py +301 -0
  89. hackagent/cli/commands/results.py +327 -0
  90. hackagent/cli/config.py +249 -0
  91. hackagent/cli/main.py +515 -0
  92. hackagent/cli/tui/__init__.py +31 -0
  93. hackagent/cli/tui/actions_logger.py +200 -0
  94. hackagent/cli/tui/app.py +288 -0
  95. hackagent/cli/tui/base.py +137 -0
  96. hackagent/cli/tui/logger.py +318 -0
  97. hackagent/cli/tui/views/__init__.py +33 -0
  98. hackagent/cli/tui/views/agents.py +488 -0
  99. hackagent/cli/tui/views/attacks.py +624 -0
  100. hackagent/cli/tui/views/config.py +244 -0
  101. hackagent/cli/tui/views/dashboard.py +307 -0
  102. hackagent/cli/tui/views/results.py +1210 -0
  103. hackagent/cli/tui/widgets/__init__.py +24 -0
  104. hackagent/cli/tui/widgets/actions.py +346 -0
  105. hackagent/cli/tui/widgets/logs.py +435 -0
  106. hackagent/cli/utils.py +276 -0
  107. hackagent/client.py +286 -0
  108. hackagent/errors.py +37 -0
  109. hackagent/logger.py +83 -0
  110. hackagent/models/__init__.py +109 -0
  111. hackagent/models/agent.py +223 -0
  112. hackagent/models/agent_request.py +129 -0
  113. hackagent/models/api_token_log.py +184 -0
  114. hackagent/models/attack.py +154 -0
  115. hackagent/models/attack_request.py +82 -0
  116. hackagent/models/checkout_session_request_request.py +76 -0
  117. hackagent/models/checkout_session_response.py +59 -0
  118. hackagent/models/choice.py +81 -0
  119. hackagent/models/choice_message.py +67 -0
  120. hackagent/models/evaluation_status_enum.py +14 -0
  121. hackagent/models/generate_error_response.py +59 -0
  122. hackagent/models/generate_request_request.py +212 -0
  123. hackagent/models/generate_success_response.py +115 -0
  124. hackagent/models/generic_error_response.py +70 -0
  125. hackagent/models/message_request.py +67 -0
  126. hackagent/models/organization.py +102 -0
  127. hackagent/models/organization_minimal.py +68 -0
  128. hackagent/models/organization_request.py +71 -0
  129. hackagent/models/paginated_agent_list.py +123 -0
  130. hackagent/models/paginated_api_token_log_list.py +123 -0
  131. hackagent/models/paginated_attack_list.py +123 -0
  132. hackagent/models/paginated_organization_list.py +123 -0
  133. hackagent/models/paginated_prompt_list.py +123 -0
  134. hackagent/models/paginated_result_list.py +123 -0
  135. hackagent/models/paginated_run_list.py +123 -0
  136. hackagent/models/paginated_user_api_key_list.py +123 -0
  137. hackagent/models/paginated_user_profile_list.py +123 -0
  138. hackagent/models/patched_agent_request.py +128 -0
  139. hackagent/models/patched_attack_request.py +92 -0
  140. hackagent/models/patched_organization_request.py +71 -0
  141. hackagent/models/patched_prompt_request.py +125 -0
  142. hackagent/models/patched_result_request.py +237 -0
  143. hackagent/models/patched_run_request.py +138 -0
  144. hackagent/models/patched_user_profile_request.py +99 -0
  145. hackagent/models/prompt.py +220 -0
  146. hackagent/models/prompt_request.py +126 -0
  147. hackagent/models/result.py +294 -0
  148. hackagent/models/result_list_evaluation_status.py +14 -0
  149. hackagent/models/result_request.py +232 -0
  150. hackagent/models/run.py +233 -0
  151. hackagent/models/run_list_status.py +12 -0
  152. hackagent/models/run_request.py +133 -0
  153. hackagent/models/status_enum.py +12 -0
  154. hackagent/models/step_type_enum.py +14 -0
  155. hackagent/models/trace.py +121 -0
  156. hackagent/models/trace_request.py +94 -0
  157. hackagent/models/usage.py +75 -0
  158. hackagent/models/user_api_key.py +201 -0
  159. hackagent/models/user_api_key_request.py +73 -0
  160. hackagent/models/user_profile.py +135 -0
  161. hackagent/models/user_profile_minimal.py +76 -0
  162. hackagent/models/user_profile_request.py +99 -0
  163. hackagent/router/__init__.py +25 -0
  164. hackagent/router/adapters/__init__.py +20 -0
  165. hackagent/router/adapters/base.py +63 -0
  166. hackagent/router/adapters/google_adk.py +671 -0
  167. hackagent/router/adapters/litellm_adapter.py +524 -0
  168. hackagent/router/adapters/openai_adapter.py +426 -0
  169. hackagent/router/router.py +969 -0
  170. hackagent/router/types.py +54 -0
  171. hackagent/tracking/__init__.py +42 -0
  172. hackagent/tracking/context.py +163 -0
  173. hackagent/tracking/decorators.py +299 -0
  174. hackagent/tracking/tracker.py +441 -0
  175. hackagent/types.py +54 -0
  176. hackagent/utils.py +194 -0
  177. hackagent/vulnerabilities/__init__.py +13 -0
  178. hackagent/vulnerabilities/prompts.py +81 -0
  179. hackagent-0.3.1.dist-info/METADATA +122 -0
  180. hackagent-0.3.1.dist-info/RECORD +183 -0
  181. hackagent-0.3.1.dist-info/WHEEL +4 -0
  182. hackagent-0.3.1.dist-info/entry_points.txt +2 -0
  183. hackagent-0.3.1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,435 @@
1
+ # Copyright 2025 - AI4I. All rights reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ Attack Log Viewer Component
17
+
18
+ A reusable Textual widget for displaying live attack execution logs
19
+ with syntax highlighting, auto-scrolling, and filtering capabilities.
20
+ """
21
+
22
+ from textual.app import ComposeResult
23
+ from textual.containers import Container
24
+ from textual.widgets import Button, RichLog, Static
25
+
26
+
27
+ class AttackLogViewer(Container):
28
+ """
29
+ A container widget for displaying attack execution logs in real-time.
30
+
31
+ This component provides:
32
+ - Live log streaming with syntax highlighting
33
+ - Color-coded log levels (INFO, WARNING, ERROR)
34
+ - Auto-scroll to latest logs
35
+ - Manual scroll capability
36
+ - Clear logs functionality
37
+ - Export logs to file
38
+ """
39
+
40
+ DEFAULT_CSS = """
41
+ AttackLogViewer {
42
+ border: solid $primary;
43
+ padding: 0;
44
+ }
45
+
46
+ AttackLogViewer .log-header {
47
+ dock: top;
48
+ height: 3;
49
+ background: $panel;
50
+ padding: 0 1;
51
+ content-align: center middle;
52
+ }
53
+
54
+ AttackLogViewer .log-controls {
55
+ dock: top;
56
+ height: 3;
57
+ background: $surface;
58
+ padding: 0 1;
59
+ layout: horizontal;
60
+ }
61
+
62
+ AttackLogViewer RichLog {
63
+ background: $surface;
64
+ border: none;
65
+ padding: 1;
66
+ height: 1fr;
67
+ width: 100%;
68
+ }
69
+
70
+ AttackLogViewer Button {
71
+ margin: 0 1;
72
+ }
73
+ """
74
+
75
+ def __init__(
76
+ self,
77
+ title: str = "Attack Execution Logs",
78
+ show_controls: bool = True,
79
+ max_lines: int = 1000,
80
+ **kwargs,
81
+ ):
82
+ """
83
+ Initialize the log viewer.
84
+
85
+ Args:
86
+ title: Title to display in the header
87
+ show_controls: Whether to show control buttons
88
+ max_lines: Maximum number of log lines to retain
89
+ **kwargs: Additional keyword arguments for Container
90
+ """
91
+ super().__init__(**kwargs)
92
+ self.log_title = title
93
+ self.show_controls = show_controls
94
+ self.max_lines = max_lines
95
+ self._auto_scroll = True
96
+ self._line_count = 0 # Track line count internally
97
+ self._log_buffer: list[str] = [] # Store log messages for copying
98
+ self._log_file = None # File handle for continuous logging
99
+ self._log_file_path = None # Path to the log file
100
+ self._initialize_log_file()
101
+
102
+ def compose(self) -> ComposeResult:
103
+ """Compose the log viewer layout."""
104
+ # Header
105
+ yield Static(
106
+ f"[bold cyan]{self.log_title}[/bold cyan]",
107
+ classes="log-header",
108
+ )
109
+
110
+ # Control buttons (optional)
111
+ if self.show_controls:
112
+ with Container(classes="log-controls"):
113
+ yield Button("Clear Logs", id="clear-logs", variant="default")
114
+ yield Button("Auto-scroll: ON", id="toggle-scroll", variant="primary")
115
+ yield Static("", id="log-count")
116
+ yield Static("", id="log-file-path", classes="log-file-info")
117
+
118
+ # Log display area
119
+ rich_log = RichLog(
120
+ highlight=True,
121
+ markup=True,
122
+ max_lines=self.max_lines,
123
+ wrap=True,
124
+ id="attack-log-display",
125
+ )
126
+ yield rich_log
127
+
128
+ def _initialize_log_file(self) -> None:
129
+ """Initialize log file for continuous writing."""
130
+ try:
131
+ import os
132
+ from datetime import datetime
133
+
134
+ # Create logs directory
135
+ log_dir = os.path.join(os.getcwd(), "logs")
136
+ os.makedirs(log_dir, exist_ok=True)
137
+
138
+ # Create log file with timestamp
139
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
140
+ self._log_file_path = os.path.join(log_dir, f"attack_logs_{timestamp}.log")
141
+ self._log_file = open(
142
+ self._log_file_path, "w", buffering=1
143
+ ) # Line buffered
144
+
145
+ except Exception:
146
+ # If file creation fails, just continue without file logging
147
+ self._log_file = None
148
+ self._log_file_path = None
149
+
150
+ def on_mount(self) -> None:
151
+ """Called when the widget is mounted."""
152
+ self.update_log_count(0)
153
+ if self._log_file_path and self.show_controls:
154
+ self._update_log_file_path_display()
155
+
156
+ def on_button_pressed(self, event: Button.Pressed) -> None:
157
+ """Handle button press events."""
158
+ if event.button.id == "clear-logs":
159
+ self.clear_logs()
160
+ elif event.button.id == "toggle-scroll":
161
+ self.toggle_auto_scroll()
162
+
163
+ def add_log(self, message: str, level: str = "INFO") -> None:
164
+ """
165
+ Add a log message to the viewer with appropriate styling.
166
+
167
+ Args:
168
+ message: The log message to display
169
+ level: Log level (INFO, WARNING, ERROR, DEBUG)
170
+ """
171
+ log_widget = self.query_one("#attack-log-display", RichLog)
172
+
173
+ # Color code based on log level
174
+ level_colors = {
175
+ "DEBUG": "dim",
176
+ "INFO": "cyan",
177
+ "WARNING": "yellow",
178
+ "ERROR": "bold red",
179
+ "CRITICAL": "bold red on white",
180
+ }
181
+
182
+ color = level_colors.get(level, "white")
183
+
184
+ # Format the message with color
185
+ if level in ["ERROR", "CRITICAL"]:
186
+ formatted_message = f"[{color}]🔴 {message}[/{color}]"
187
+ elif level == "WARNING":
188
+ formatted_message = f"[{color}]⚠️ {message}[/{color}]"
189
+ elif level == "DEBUG":
190
+ formatted_message = f"[{color}]🔍 {message}[/{color}]"
191
+ else: # INFO and default
192
+ formatted_message = f"[{color}]{message}[/{color}]"
193
+
194
+ # Add to log display
195
+ log_widget.write(formatted_message)
196
+
197
+ # Store in buffer for copying (strip Rich markup)
198
+ plain_message = message # Store the original message without formatting
199
+ log_entry = f"[{level}] {plain_message}"
200
+ self._log_buffer.append(log_entry)
201
+
202
+ # Write to log file immediately
203
+ if self._log_file:
204
+ try:
205
+ self._log_file.write(log_entry + "\n")
206
+ self._log_file.flush() # Ensure it's written immediately
207
+ except Exception:
208
+ pass # Silently continue if file write fails
209
+
210
+ # Auto-scroll to bottom if enabled
211
+ if self._auto_scroll:
212
+ log_widget.scroll_end(animate=False)
213
+
214
+ # Update log count
215
+ self._line_count += 1
216
+ self.update_log_count(self._line_count)
217
+
218
+ def add_step_header(self, step_name: str, step_number: int = 0) -> None:
219
+ """
220
+ Add a prominent step header to visually separate pipeline steps.
221
+
222
+ Args:
223
+ step_name: Name of the step
224
+ step_number: Step number (0 for no number)
225
+ """
226
+ log_widget = self.query_one("#attack-log-display", RichLog)
227
+
228
+ # Create a visual separator
229
+ separator = "─" * 60
230
+ if step_number > 0:
231
+ header = f"\n[bold magenta]{separator}\n🎯 STEP {step_number}: {step_name}\n{separator}[/bold magenta]\n"
232
+ else:
233
+ header = f"\n[bold magenta]{separator}\n🎯 {step_name}\n{separator}[/bold magenta]\n"
234
+
235
+ log_widget.write(header)
236
+
237
+ if self._auto_scroll:
238
+ log_widget.scroll_end(animate=False)
239
+
240
+ def clear_logs(self) -> None:
241
+ """Clear all log messages from the viewer."""
242
+ log_widget = self.query_one("#attack-log-display", RichLog)
243
+ log_widget.clear()
244
+ self._line_count = 0
245
+ self._log_buffer.clear()
246
+
247
+ # Close current log file and create a new one
248
+ if self._log_file:
249
+ try:
250
+ self._log_file.close()
251
+ except Exception:
252
+ pass
253
+
254
+ self._initialize_log_file()
255
+ if self._log_file_path:
256
+ self._update_log_file_path_display()
257
+
258
+ self.update_log_count(0)
259
+
260
+ def copy_logs(self) -> None:
261
+ """Copy all log messages to clipboard or save to file."""
262
+ if not self._log_buffer:
263
+ return
264
+
265
+ log_text = "\n".join(self._log_buffer)
266
+
267
+ # Try multiple clipboard methods
268
+ copied = False
269
+
270
+ # Method 1: Try subprocess clipboard tools first (more reliable in containers/SSH)
271
+ try:
272
+ import subprocess
273
+ import platform
274
+
275
+ system = platform.system()
276
+ if system == "Linux":
277
+ # Try xclip first, then xsel
278
+ try:
279
+ subprocess.run(
280
+ ["xclip", "-selection", "clipboard"],
281
+ input=log_text.encode(),
282
+ check=True,
283
+ stderr=subprocess.DEVNULL,
284
+ timeout=2,
285
+ )
286
+ copied = True
287
+ except (
288
+ FileNotFoundError,
289
+ subprocess.CalledProcessError,
290
+ subprocess.TimeoutExpired,
291
+ ):
292
+ try:
293
+ subprocess.run(
294
+ ["xsel", "--clipboard", "--input"],
295
+ input=log_text.encode(),
296
+ check=True,
297
+ stderr=subprocess.DEVNULL,
298
+ timeout=2,
299
+ )
300
+ copied = True
301
+ except (
302
+ FileNotFoundError,
303
+ subprocess.CalledProcessError,
304
+ subprocess.TimeoutExpired,
305
+ ):
306
+ pass
307
+ elif system == "Darwin": # macOS
308
+ subprocess.run(
309
+ ["pbcopy"], input=log_text.encode(), check=True, timeout=2
310
+ )
311
+ copied = True
312
+ elif system == "Windows":
313
+ subprocess.run(["clip"], input=log_text.encode(), check=True, timeout=2)
314
+ copied = True
315
+
316
+ if copied:
317
+ pass
318
+ except Exception:
319
+ pass
320
+
321
+ # Method 2: Try pyperclip as fallback (if subprocess failed)
322
+ if not copied:
323
+ try:
324
+ import pyperclip
325
+
326
+ pyperclip.copy(log_text)
327
+ copied = True
328
+ except ImportError:
329
+ pass
330
+ except Exception:
331
+ pass
332
+
333
+ # Fallback: Save to file
334
+ if not copied:
335
+ try:
336
+ import os
337
+
338
+ # Create a more permanent location in the project
339
+ log_dir = os.path.join(os.getcwd(), "logs")
340
+ os.makedirs(log_dir, exist_ok=True)
341
+
342
+ from datetime import datetime
343
+
344
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
345
+ log_file = os.path.join(log_dir, f"attack_logs_{timestamp}.log")
346
+
347
+ with open(log_file, "w") as f:
348
+ f.write(log_text)
349
+ except Exception:
350
+ pass
351
+
352
+ def view_in_pager(self) -> None:
353
+ """View logs in a pager (less) for easy selection and navigation."""
354
+ if not self._log_buffer:
355
+ return
356
+
357
+ try:
358
+ import tempfile
359
+ import subprocess
360
+ import os
361
+
362
+ # Save to temporary file
363
+ log_text = "\n".join(self._log_buffer)
364
+ temp_file = tempfile.NamedTemporaryFile(
365
+ mode="w", suffix=".log", delete=False
366
+ )
367
+ temp_file.write(log_text)
368
+ temp_file.close()
369
+
370
+ # Suspend the TUI and open in pager
371
+ self.app.suspend()
372
+
373
+ # Try less first (with mouse support), fall back to more
374
+ pager = os.environ.get("PAGER", "less")
375
+ if pager == "less":
376
+ # Enable mouse, color, and exit if content fits on screen
377
+ subprocess.run(["less", "-R", "-X", "--mouse", temp_file.name])
378
+ else:
379
+ subprocess.run([pager, temp_file.name])
380
+
381
+ # Clean up
382
+ os.unlink(temp_file.name)
383
+
384
+ # Resume the TUI
385
+ self.app.refresh()
386
+
387
+ except Exception:
388
+ self.app.refresh() # Make sure we resume even on error
389
+ pass
390
+
391
+ def toggle_auto_scroll(self) -> None:
392
+ """Toggle automatic scrolling to latest logs."""
393
+ self._auto_scroll = not self._auto_scroll
394
+ button = self.query_one("#toggle-scroll", Button)
395
+ button.label = f"Auto-scroll: {'ON' if self._auto_scroll else 'OFF'}"
396
+ button.variant = "primary" if self._auto_scroll else "default"
397
+
398
+ def update_log_count(self, count: int) -> None:
399
+ """
400
+ Update the log count display.
401
+
402
+ Args:
403
+ count: Number of log lines currently displayed
404
+ """
405
+ if self.show_controls:
406
+ count_widget = self.query_one("#log-count", Static)
407
+ count_widget.update(f"[dim]Lines: {count}/{self.max_lines}[/dim]")
408
+
409
+ def _update_log_file_path_display(self) -> None:
410
+ """Update the log file path display."""
411
+ if self.show_controls and self._log_file_path:
412
+ try:
413
+ path_widget = self.query_one("#log-file-path", Static)
414
+ path_widget.update(f"[dim]📄 Logs: {self._log_file_path}[/dim]")
415
+ except Exception:
416
+ pass # Widget might not be mounted yet
417
+
418
+ def get_log_text(self) -> str:
419
+ """
420
+ Get all log text as a plain string (for export).
421
+
422
+ Returns:
423
+ All log messages as plain text
424
+ """
425
+ return "\n".join(self._log_buffer)
426
+
427
+ def load_logs_from_buffer(self, buffer: list[tuple[str, str]]) -> None:
428
+ """
429
+ Load logs from a buffer (e.g., from TUILogHandler).
430
+
431
+ Args:
432
+ buffer: List of (message, level) tuples
433
+ """
434
+ for message, level in buffer:
435
+ self.add_log(message, level) # add_log will handle line count
hackagent/cli/utils.py ADDED
@@ -0,0 +1,276 @@
1
+ # Copyright 2025 - AI4I. All rights reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ CLI Utilities
17
+
18
+ Common utilities for the HackAgent CLI including error handling,
19
+ formatting, and helper functions.
20
+ """
21
+
22
+ import functools
23
+ import json
24
+ from pathlib import Path
25
+ from typing import Any, Dict
26
+
27
+ import click
28
+ from rich.console import Console
29
+ from rich.panel import Panel
30
+ from rich.table import Table
31
+ from rich.text import Text
32
+ from rich.traceback import Traceback
33
+
34
+ from hackagent.errors import ApiError, HackAgentError
35
+
36
+ console = Console()
37
+
38
+
39
+ def handle_errors(func):
40
+ """Decorator for consistent error handling across CLI commands"""
41
+
42
+ @functools.wraps(func)
43
+ def wrapper(*args, **kwargs):
44
+ try:
45
+ return func(*args, **kwargs)
46
+ except HackAgentError as e:
47
+ console.print(f"[bold red]❌ HackAgent Error: {str(e)}")
48
+ if console._environ.get("HACKAGENT_DEBUG"):
49
+ console.print(Traceback())
50
+ raise click.ClickException(str(e))
51
+ except ApiError as e:
52
+ console.print(f"[bold red]❌ API Error: {str(e)}")
53
+ if console._environ.get("HACKAGENT_DEBUG"):
54
+ console.print(Traceback())
55
+ raise click.ClickException(str(e))
56
+ except ValueError as e:
57
+ console.print(f"[bold red]❌ Configuration Error: {str(e)}")
58
+ raise click.ClickException(str(e))
59
+ except FileNotFoundError as e:
60
+ console.print(f"[bold red]❌ File Not Found: {str(e)}")
61
+ raise click.ClickException(str(e))
62
+ except Exception as e:
63
+ console.print(f"[bold red]❌ Unexpected error: {str(e)}")
64
+ if console._environ.get("HACKAGENT_DEBUG"):
65
+ console.print(Traceback())
66
+ raise click.ClickException(str(e))
67
+
68
+ return wrapper
69
+
70
+
71
+ def load_config_file(path: str) -> Dict[str, Any]:
72
+ """Load configuration from YAML or JSON file"""
73
+ config_path = Path(path)
74
+
75
+ if not config_path.exists():
76
+ raise FileNotFoundError(f"Configuration file not found: {path}")
77
+
78
+ try:
79
+ with open(config_path) as f:
80
+ if config_path.suffix.lower() in [".yaml", ".yml"]:
81
+ try:
82
+ import yaml
83
+
84
+ return yaml.safe_load(f) or {}
85
+ except ImportError:
86
+ raise click.ClickException(
87
+ "PyYAML required for YAML config files. Install with: pip install pyyaml"
88
+ )
89
+ else:
90
+ return json.load(f)
91
+ except json.JSONDecodeError as e:
92
+ raise click.ClickException(f"Invalid JSON in config file {path}: {e}")
93
+ except Exception as e:
94
+ raise click.ClickException(f"Failed to load config file {path}: {e}")
95
+
96
+
97
+ def display_results_table(results: Any, title: str = "Results") -> None:
98
+ """Display results in a formatted table"""
99
+ import pandas as pd
100
+
101
+ if isinstance(results, pd.DataFrame):
102
+ if results.empty:
103
+ console.print(f"[yellow]ℹ️ No {title.lower()} found")
104
+ return
105
+
106
+ table = Table(title=title, show_header=True, header_style="bold cyan")
107
+
108
+ # Add columns
109
+ for column in results.columns:
110
+ table.add_column(str(column))
111
+
112
+ # Add rows (limit to first 20 for display)
113
+ display_results = results.head(20)
114
+ for _, row in display_results.iterrows():
115
+ table.add_row(*[str(value) for value in row])
116
+
117
+ console.print(table)
118
+
119
+ if len(results) > 20:
120
+ console.print(f"[dim]... and {len(results) - 20} more rows")
121
+
122
+ elif isinstance(results, list):
123
+ if not results:
124
+ console.print(f"[yellow]ℹ️ No {title.lower()} found")
125
+ return
126
+
127
+ # Try to create table from list of dicts
128
+ if results and isinstance(results[0], dict):
129
+ table = Table(title=title, show_header=True, header_style="bold cyan")
130
+
131
+ # Get all unique keys for columns
132
+ all_keys = set()
133
+ for item in results:
134
+ all_keys.update(item.keys())
135
+
136
+ # Add columns
137
+ for key in sorted(all_keys):
138
+ table.add_column(str(key))
139
+
140
+ # Add rows (limit to first 20)
141
+ for item in results[:20]:
142
+ row_values = []
143
+ for key in sorted(all_keys):
144
+ value = item.get(key, "")
145
+ row_values.append(str(value))
146
+ table.add_row(*row_values)
147
+
148
+ console.print(table)
149
+
150
+ if len(results) > 20:
151
+ console.print(f"[dim]... and {len(results) - 20} more rows")
152
+ else:
153
+ # Simple list display
154
+ for i, item in enumerate(results[:20], 1):
155
+ console.print(f"{i}. {item}")
156
+
157
+ if len(results) > 20:
158
+ console.print(f"[dim]... and {len(results) - 20} more items")
159
+
160
+ else:
161
+ # Fallback to JSON-like display
162
+ console.print_json(data=results)
163
+
164
+
165
+ def display_success(message: str) -> None:
166
+ """Display success message with formatting"""
167
+ console.print(f"[bold green]✅ {message}")
168
+
169
+
170
+ def display_warning(message: str) -> None:
171
+ """Display warning message with formatting"""
172
+ console.print(f"[bold yellow]⚠️ {message}")
173
+
174
+
175
+ def display_error(message: str) -> None:
176
+ """Display error message with formatting"""
177
+ console.print(f"[bold red]❌ {message}")
178
+
179
+
180
+ def display_info(message: str) -> None:
181
+ """Display info message with formatting"""
182
+ console.print(f"[cyan]ℹ️ {message}")
183
+
184
+
185
+ def confirm_action(message: str, default: bool = False) -> bool:
186
+ """Get user confirmation for dangerous actions"""
187
+ return click.confirm(f"⚠️ {message}", default=default)
188
+
189
+
190
+ def get_agent_type_enum(agent_type: str):
191
+ """Convert string agent type to AgentTypeEnum"""
192
+ from hackagent.router.types import AgentTypeEnum
193
+
194
+ # Normalize the input
195
+ normalized = agent_type.upper().replace("-", "_").replace(" ", "_")
196
+
197
+ # Map common variations
198
+ type_mapping = {
199
+ "GOOGLE_ADK": AgentTypeEnum.GOOGLE_ADK,
200
+ "GOOGLE-ADK": AgentTypeEnum.GOOGLE_ADK,
201
+ "ADK": AgentTypeEnum.GOOGLE_ADK,
202
+ "LANGCHAIN": AgentTypeEnum.LANGCHAIN,
203
+ "LANG_CHAIN": AgentTypeEnum.LANGCHAIN,
204
+ "LITELLM": AgentTypeEnum.LITELLM,
205
+ "LITE_LLM": AgentTypeEnum.LITELLM,
206
+ "OPENAI_SDK": AgentTypeEnum.OPENAI_SDK,
207
+ "OPENAI-SDK": AgentTypeEnum.OPENAI_SDK,
208
+ "OPENAI": AgentTypeEnum.OPENAI_SDK,
209
+ "OTHER": AgentTypeEnum.UNKNOWN,
210
+ "UNKNOWN": AgentTypeEnum.UNKNOWN,
211
+ }
212
+
213
+ if normalized in type_mapping:
214
+ return type_mapping[normalized]
215
+
216
+ try:
217
+ return AgentTypeEnum(normalized)
218
+ except ValueError:
219
+ # If the type is not recognized, fallback to UNKNOWN
220
+ console.print(
221
+ f"[yellow]⚠️ Agent type '{agent_type}' not recognized, using 'UNKNOWN'[/yellow]"
222
+ )
223
+ return AgentTypeEnum.UNKNOWN
224
+
225
+
226
+ def format_duration(seconds: float) -> str:
227
+ """Format duration in seconds to human readable format"""
228
+ if seconds < 60:
229
+ return f"{seconds:.1f}s"
230
+ elif seconds < 3600:
231
+ minutes = seconds / 60
232
+ return f"{minutes:.1f}m"
233
+ else:
234
+ hours = seconds / 3600
235
+ return f"{hours:.1f}h"
236
+
237
+
238
+ def create_status_panel(title: str, content: str, status: str = "info") -> Panel:
239
+ """Create a status panel with appropriate styling"""
240
+ style_map = {
241
+ "success": "green",
242
+ "error": "red",
243
+ "warning": "yellow",
244
+ "info": "cyan",
245
+ }
246
+
247
+ style = style_map.get(status, "cyan")
248
+ return Panel(
249
+ Text(content, style=style), title=title, border_style=style, padding=(1, 2)
250
+ )
251
+
252
+
253
+ def launch_tui(cli_config, initial_tab: str = "dashboard", initial_data: dict = None):
254
+ """Launch the TUI application with specified tab and optional initial data
255
+
256
+ Args:
257
+ cli_config: CLI configuration object
258
+ initial_tab: Which tab to show initially (default: "dashboard")
259
+ initial_data: Initial data to pre-fill in the tab (default: None)
260
+ """
261
+ try:
262
+ from hackagent.cli.tui import HackAgentTUI
263
+
264
+ app = HackAgentTUI(
265
+ cli_config, initial_tab=initial_tab, initial_data=initial_data
266
+ )
267
+ app.run()
268
+
269
+ except ImportError:
270
+ console.print("[bold red]❌ TUI dependencies not installed[/bold red]")
271
+ console.print("\n[cyan]💡 Install with:[/cyan]")
272
+ console.print(" uv add textual")
273
+ raise click.ClickException("TUI dependencies not installed")
274
+ except Exception as e:
275
+ console.print(f"[bold red]❌ TUI failed to start: {e}[/bold red]")
276
+ raise click.ClickException(f"TUI failed to start: {e}")