code-puppy 0.0.170__py3-none-any.whl → 0.0.172__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 (66) hide show
  1. code_puppy/agent.py +10 -2
  2. code_puppy/agents/agent_creator_agent.py +0 -3
  3. code_puppy/agents/agent_qa_kitten.py +203 -0
  4. code_puppy/agents/base_agent.py +9 -0
  5. code_puppy/command_line/command_handler.py +68 -28
  6. code_puppy/command_line/mcp/add_command.py +1 -1
  7. code_puppy/command_line/mcp/base.py +1 -1
  8. code_puppy/command_line/mcp/install_command.py +1 -1
  9. code_puppy/command_line/mcp/list_command.py +1 -1
  10. code_puppy/command_line/mcp/search_command.py +1 -1
  11. code_puppy/command_line/mcp/start_all_command.py +1 -1
  12. code_puppy/command_line/mcp/status_command.py +2 -2
  13. code_puppy/command_line/mcp/stop_all_command.py +1 -1
  14. code_puppy/command_line/mcp/utils.py +1 -1
  15. code_puppy/command_line/mcp/wizard_utils.py +2 -2
  16. code_puppy/config.py +142 -12
  17. code_puppy/http_utils.py +50 -24
  18. code_puppy/{mcp → mcp_}/config_wizard.py +1 -1
  19. code_puppy/{mcp → mcp_}/examples/retry_example.py +1 -1
  20. code_puppy/{mcp → mcp_}/managed_server.py +1 -1
  21. code_puppy/{mcp → mcp_}/server_registry_catalog.py +1 -3
  22. code_puppy/message_history_processor.py +121 -125
  23. code_puppy/state_management.py +86 -127
  24. code_puppy/tools/__init__.py +103 -6
  25. code_puppy/tools/browser/__init__.py +0 -0
  26. code_puppy/tools/browser/browser_control.py +293 -0
  27. code_puppy/tools/browser/browser_interactions.py +552 -0
  28. code_puppy/tools/browser/browser_locators.py +642 -0
  29. code_puppy/tools/browser/browser_navigation.py +251 -0
  30. code_puppy/tools/browser/browser_screenshot.py +242 -0
  31. code_puppy/tools/browser/browser_scripts.py +478 -0
  32. code_puppy/tools/browser/browser_workflows.py +196 -0
  33. code_puppy/tools/browser/camoufox_manager.py +194 -0
  34. code_puppy/tools/browser/vqa_agent.py +66 -0
  35. code_puppy/tools/browser_control.py +293 -0
  36. code_puppy/tools/browser_interactions.py +552 -0
  37. code_puppy/tools/browser_locators.py +642 -0
  38. code_puppy/tools/browser_navigation.py +251 -0
  39. code_puppy/tools/browser_screenshot.py +278 -0
  40. code_puppy/tools/browser_scripts.py +478 -0
  41. code_puppy/tools/browser_workflows.py +215 -0
  42. code_puppy/tools/camoufox_manager.py +150 -0
  43. code_puppy/tools/command_runner.py +12 -7
  44. code_puppy/tools/file_operations.py +7 -7
  45. code_puppy/tui/app.py +4 -2
  46. code_puppy/tui/components/custom_widgets.py +1 -1
  47. code_puppy/tui/screens/mcp_install_wizard.py +8 -8
  48. {code_puppy-0.0.170.dist-info → code_puppy-0.0.172.dist-info}/METADATA +4 -2
  49. {code_puppy-0.0.170.dist-info → code_puppy-0.0.172.dist-info}/RECORD +66 -47
  50. /code_puppy/{mcp → mcp_}/__init__.py +0 -0
  51. /code_puppy/{mcp → mcp_}/async_lifecycle.py +0 -0
  52. /code_puppy/{mcp → mcp_}/blocking_startup.py +0 -0
  53. /code_puppy/{mcp → mcp_}/captured_stdio_server.py +0 -0
  54. /code_puppy/{mcp → mcp_}/circuit_breaker.py +0 -0
  55. /code_puppy/{mcp → mcp_}/dashboard.py +0 -0
  56. /code_puppy/{mcp → mcp_}/error_isolation.py +0 -0
  57. /code_puppy/{mcp → mcp_}/health_monitor.py +0 -0
  58. /code_puppy/{mcp → mcp_}/manager.py +0 -0
  59. /code_puppy/{mcp → mcp_}/registry.py +0 -0
  60. /code_puppy/{mcp → mcp_}/retry_manager.py +0 -0
  61. /code_puppy/{mcp → mcp_}/status_tracker.py +0 -0
  62. /code_puppy/{mcp → mcp_}/system_tools.py +0 -0
  63. {code_puppy-0.0.170.data → code_puppy-0.0.172.data}/data/code_puppy/models.json +0 -0
  64. {code_puppy-0.0.170.dist-info → code_puppy-0.0.172.dist-info}/WHEEL +0 -0
  65. {code_puppy-0.0.170.dist-info → code_puppy-0.0.172.dist-info}/entry_points.txt +0 -0
  66. {code_puppy-0.0.170.dist-info → code_puppy-0.0.172.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,478 @@
1
+ """JavaScript execution and advanced page manipulation tools."""
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+ from pydantic_ai import RunContext
6
+
7
+ from code_puppy.messaging import emit_info
8
+ from code_puppy.tools.common import generate_group_id
9
+
10
+ from .camoufox_manager import get_camoufox_manager
11
+
12
+
13
+ async def execute_javascript(
14
+ script: str,
15
+ timeout: int = 30000,
16
+ ) -> Dict[str, Any]:
17
+ """Execute JavaScript code in the browser context."""
18
+ group_id = generate_group_id("browser_execute_js", script[:100])
19
+ emit_info(
20
+ f"[bold white on blue] BROWSER EXECUTE JS [/bold white on blue] 📜 script='{script[:100]}{'...' if len(script) > 100 else ''}'",
21
+ message_group=group_id,
22
+ )
23
+ try:
24
+ browser_manager = get_camoufox_manager()
25
+ page = await browser_manager.get_current_page()
26
+
27
+ if not page:
28
+ return {"success": False, "error": "No active browser page available"}
29
+
30
+ # Execute JavaScript
31
+ result = await page.evaluate(script, timeout=timeout)
32
+
33
+ emit_info(
34
+ "[green]JavaScript executed successfully[/green]", message_group=group_id
35
+ )
36
+
37
+ return {"success": True, "script": script, "result": result}
38
+
39
+ except Exception as e:
40
+ emit_info(
41
+ f"[red]JavaScript execution failed: {str(e)}[/red]", message_group=group_id
42
+ )
43
+ return {"success": False, "error": str(e), "script": script}
44
+
45
+
46
+ async def scroll_page(
47
+ direction: str = "down",
48
+ amount: int = 3,
49
+ element_selector: Optional[str] = None,
50
+ ) -> Dict[str, Any]:
51
+ """Scroll the page or a specific element."""
52
+ target = element_selector or "page"
53
+ group_id = generate_group_id("browser_scroll", f"{direction}_{amount}_{target}")
54
+ emit_info(
55
+ f"[bold white on blue] BROWSER SCROLL [/bold white on blue] 📋 direction={direction} amount={amount} target='{target}'",
56
+ message_group=group_id,
57
+ )
58
+ try:
59
+ browser_manager = get_camoufox_manager()
60
+ page = await browser_manager.get_current_page()
61
+
62
+ if not page:
63
+ return {"success": False, "error": "No active browser page available"}
64
+
65
+ if element_selector:
66
+ # Scroll specific element
67
+ element = page.locator(element_selector)
68
+ await element.scroll_into_view_if_needed()
69
+
70
+ # Get element's current scroll position and dimensions
71
+ scroll_info = await element.evaluate("""
72
+ el => {
73
+ const rect = el.getBoundingClientRect();
74
+ return {
75
+ scrollTop: el.scrollTop,
76
+ scrollLeft: el.scrollLeft,
77
+ scrollHeight: el.scrollHeight,
78
+ scrollWidth: el.scrollWidth,
79
+ clientHeight: el.clientHeight,
80
+ clientWidth: el.clientWidth
81
+ };
82
+ }
83
+ """)
84
+
85
+ # Calculate scroll amount based on element size
86
+ scroll_amount = scroll_info["clientHeight"] * amount / 3
87
+
88
+ if direction.lower() == "down":
89
+ await element.evaluate(f"el => el.scrollTop += {scroll_amount}")
90
+ elif direction.lower() == "up":
91
+ await element.evaluate(f"el => el.scrollTop -= {scroll_amount}")
92
+ elif direction.lower() == "left":
93
+ await element.evaluate(f"el => el.scrollLeft -= {scroll_amount}")
94
+ elif direction.lower() == "right":
95
+ await element.evaluate(f"el => el.scrollLeft += {scroll_amount}")
96
+
97
+ target = f"element '{element_selector}'"
98
+
99
+ else:
100
+ # Scroll page
101
+ viewport_height = await page.evaluate("() => window.innerHeight")
102
+ scroll_amount = viewport_height * amount / 3
103
+
104
+ if direction.lower() == "down":
105
+ await page.evaluate(f"window.scrollBy(0, {scroll_amount})")
106
+ elif direction.lower() == "up":
107
+ await page.evaluate(f"window.scrollBy(0, -{scroll_amount})")
108
+ elif direction.lower() == "left":
109
+ await page.evaluate(f"window.scrollBy(-{scroll_amount}, 0)")
110
+ elif direction.lower() == "right":
111
+ await page.evaluate(f"window.scrollBy({scroll_amount}, 0)")
112
+
113
+ target = "page"
114
+
115
+ # Get current scroll position
116
+ scroll_pos = await page.evaluate("""
117
+ () => ({
118
+ x: window.pageXOffset,
119
+ y: window.pageYOffset
120
+ })
121
+ """)
122
+
123
+ emit_info(
124
+ f"[green]Scrolled {target} {direction}[/green]", message_group=group_id
125
+ )
126
+
127
+ return {
128
+ "success": True,
129
+ "direction": direction,
130
+ "amount": amount,
131
+ "target": target,
132
+ "scroll_position": scroll_pos,
133
+ }
134
+
135
+ except Exception as e:
136
+ return {
137
+ "success": False,
138
+ "error": str(e),
139
+ "direction": direction,
140
+ "element_selector": element_selector,
141
+ }
142
+
143
+
144
+ async def scroll_to_element(
145
+ selector: str,
146
+ timeout: int = 10000,
147
+ ) -> Dict[str, Any]:
148
+ """Scroll to bring an element into view."""
149
+ group_id = generate_group_id("browser_scroll_to_element", selector[:100])
150
+ emit_info(
151
+ f"[bold white on blue] BROWSER SCROLL TO ELEMENT [/bold white on blue] 🎯 selector='{selector}'",
152
+ message_group=group_id,
153
+ )
154
+ try:
155
+ browser_manager = get_camoufox_manager()
156
+ page = await browser_manager.get_current_page()
157
+
158
+ if not page:
159
+ return {"success": False, "error": "No active browser page available"}
160
+
161
+ element = page.locator(selector)
162
+ await element.wait_for(state="attached", timeout=timeout)
163
+ await element.scroll_into_view_if_needed()
164
+
165
+ # Check if element is now visible
166
+ is_visible = await element.is_visible()
167
+
168
+ emit_info(
169
+ f"[green]Scrolled to element: {selector}[/green]", message_group=group_id
170
+ )
171
+
172
+ return {"success": True, "selector": selector, "visible": is_visible}
173
+
174
+ except Exception as e:
175
+ return {"success": False, "error": str(e), "selector": selector}
176
+
177
+
178
+ async def set_viewport_size(
179
+ width: int,
180
+ height: int,
181
+ ) -> Dict[str, Any]:
182
+ """Set the viewport size."""
183
+ group_id = generate_group_id("browser_set_viewport", f"{width}x{height}")
184
+ emit_info(
185
+ f"[bold white on blue] BROWSER SET VIEWPORT [/bold white on blue] 🖥️ size={width}x{height}",
186
+ message_group=group_id,
187
+ )
188
+ try:
189
+ browser_manager = get_camoufox_manager()
190
+ page = await browser_manager.get_current_page()
191
+
192
+ if not page:
193
+ return {"success": False, "error": "No active browser page available"}
194
+
195
+ await page.set_viewport_size({"width": width, "height": height})
196
+
197
+ emit_info(
198
+ f"[green]Set viewport size to {width}x{height}[/green]",
199
+ message_group=group_id,
200
+ )
201
+
202
+ return {"success": True, "width": width, "height": height}
203
+
204
+ except Exception as e:
205
+ return {"success": False, "error": str(e), "width": width, "height": height}
206
+
207
+
208
+ async def wait_for_element(
209
+ selector: str,
210
+ state: str = "visible",
211
+ timeout: int = 30000,
212
+ ) -> Dict[str, Any]:
213
+ """Wait for an element to reach a specific state."""
214
+ group_id = generate_group_id("browser_wait_for_element", f"{selector[:50]}_{state}")
215
+ emit_info(
216
+ f"[bold white on blue] BROWSER WAIT FOR ELEMENT [/bold white on blue] ⏱️ selector='{selector}' state={state} timeout={timeout}ms",
217
+ message_group=group_id,
218
+ )
219
+ try:
220
+ browser_manager = get_camoufox_manager()
221
+ page = await browser_manager.get_current_page()
222
+
223
+ if not page:
224
+ return {"success": False, "error": "No active browser page available"}
225
+
226
+ element = page.locator(selector)
227
+ await element.wait_for(state=state, timeout=timeout)
228
+
229
+ emit_info(
230
+ f"[green]Element {selector} is now {state}[/green]", message_group=group_id
231
+ )
232
+
233
+ return {"success": True, "selector": selector, "state": state}
234
+
235
+ except Exception as e:
236
+ return {"success": False, "error": str(e), "selector": selector, "state": state}
237
+
238
+
239
+
240
+
241
+
242
+ async def highlight_element(
243
+ selector: str,
244
+ color: str = "red",
245
+ timeout: int = 10000,
246
+ ) -> Dict[str, Any]:
247
+ """Highlight an element with a colored border."""
248
+ group_id = generate_group_id(
249
+ "browser_highlight_element", f"{selector[:50]}_{color}"
250
+ )
251
+ emit_info(
252
+ f"[bold white on blue] BROWSER HIGHLIGHT ELEMENT [/bold white on blue] 🔦 selector='{selector}' color={color}",
253
+ message_group=group_id,
254
+ )
255
+ try:
256
+ browser_manager = get_camoufox_manager()
257
+ page = await browser_manager.get_current_page()
258
+
259
+ if not page:
260
+ return {"success": False, "error": "No active browser page available"}
261
+
262
+ element = page.locator(selector)
263
+ await element.wait_for(state="visible", timeout=timeout)
264
+
265
+ # Add highlight style
266
+ highlight_script = f"""
267
+ el => {{
268
+ el.style.outline = '3px solid {color}';
269
+ el.style.outlineOffset = '2px';
270
+ el.style.backgroundColor = '{color}20'; // 20% opacity
271
+ el.setAttribute('data-highlighted', 'true');
272
+ }}
273
+ """
274
+
275
+ await element.evaluate(highlight_script)
276
+
277
+ emit_info(
278
+ f"[green]Highlighted element: {selector}[/green]", message_group=group_id
279
+ )
280
+
281
+ return {"success": True, "selector": selector, "color": color}
282
+
283
+ except Exception as e:
284
+ return {"success": False, "error": str(e), "selector": selector}
285
+
286
+
287
+ async def clear_highlights() -> Dict[str, Any]:
288
+ """Clear all element highlights."""
289
+ group_id = generate_group_id("browser_clear_highlights")
290
+ emit_info(
291
+ "[bold white on blue] BROWSER CLEAR HIGHLIGHTS [/bold white on blue] 🧹",
292
+ message_group=group_id,
293
+ )
294
+ try:
295
+ browser_manager = get_camoufox_manager()
296
+ page = await browser_manager.get_current_page()
297
+
298
+ if not page:
299
+ return {"success": False, "error": "No active browser page available"}
300
+
301
+ # Remove all highlights
302
+ clear_script = """
303
+ () => {
304
+ const highlighted = document.querySelectorAll('[data-highlighted="true"]');
305
+ highlighted.forEach(el => {
306
+ el.style.outline = '';
307
+ el.style.outlineOffset = '';
308
+ el.style.backgroundColor = '';
309
+ el.removeAttribute('data-highlighted');
310
+ });
311
+ return highlighted.length;
312
+ }
313
+ """
314
+
315
+ count = await page.evaluate(clear_script)
316
+
317
+ emit_info(f"[green]Cleared {count} highlights[/green]", message_group=group_id)
318
+
319
+ return {"success": True, "cleared_count": count}
320
+
321
+ except Exception as e:
322
+ return {"success": False, "error": str(e)}
323
+
324
+
325
+ # Tool registration functions
326
+ def register_execute_javascript(agent):
327
+ """Register the JavaScript execution tool."""
328
+
329
+ @agent.tool
330
+ async def browser_execute_js(
331
+ context: RunContext,
332
+ script: str,
333
+ timeout: int = 30000,
334
+ ) -> Dict[str, Any]:
335
+ """
336
+ Execute JavaScript code in the browser context.
337
+
338
+ Args:
339
+ script: JavaScript code to execute
340
+ timeout: Timeout in milliseconds
341
+
342
+ Returns:
343
+ Dict with execution results
344
+ """
345
+ return await execute_javascript(script, timeout)
346
+
347
+
348
+ def register_scroll_page(agent):
349
+ """Register the scroll page tool."""
350
+
351
+ @agent.tool
352
+ async def browser_scroll(
353
+ context: RunContext,
354
+ direction: str = "down",
355
+ amount: int = 3,
356
+ element_selector: Optional[str] = None,
357
+ ) -> Dict[str, Any]:
358
+ """
359
+ Scroll the page or a specific element.
360
+
361
+ Args:
362
+ direction: Scroll direction (up, down, left, right)
363
+ amount: Scroll amount multiplier (1-10)
364
+ element_selector: Optional selector to scroll specific element
365
+
366
+ Returns:
367
+ Dict with scroll results
368
+ """
369
+ return await scroll_page(direction, amount, element_selector)
370
+
371
+
372
+ def register_scroll_to_element(agent):
373
+ """Register the scroll to element tool."""
374
+
375
+ @agent.tool
376
+ async def browser_scroll_to_element(
377
+ context: RunContext,
378
+ selector: str,
379
+ timeout: int = 10000,
380
+ ) -> Dict[str, Any]:
381
+ """
382
+ Scroll to bring an element into view.
383
+
384
+ Args:
385
+ selector: CSS or XPath selector for the element
386
+ timeout: Timeout in milliseconds
387
+
388
+ Returns:
389
+ Dict with scroll results
390
+ """
391
+ return await scroll_to_element(selector, timeout)
392
+
393
+
394
+ def register_set_viewport_size(agent):
395
+ """Register the viewport size tool."""
396
+
397
+ @agent.tool
398
+ async def browser_set_viewport(
399
+ context: RunContext,
400
+ width: int,
401
+ height: int,
402
+ ) -> Dict[str, Any]:
403
+ """
404
+ Set the browser viewport size.
405
+
406
+ Args:
407
+ width: Viewport width in pixels
408
+ height: Viewport height in pixels
409
+
410
+ Returns:
411
+ Dict with viewport size results
412
+ """
413
+ return await set_viewport_size(width, height)
414
+
415
+
416
+ def register_wait_for_element(agent):
417
+ """Register the wait for element tool."""
418
+
419
+ @agent.tool
420
+ async def browser_wait_for_element(
421
+ context: RunContext,
422
+ selector: str,
423
+ state: str = "visible",
424
+ timeout: int = 30000,
425
+ ) -> Dict[str, Any]:
426
+ """
427
+ Wait for an element to reach a specific state.
428
+
429
+ Args:
430
+ selector: CSS or XPath selector for the element
431
+ state: State to wait for (visible, hidden, attached, detached)
432
+ timeout: Timeout in milliseconds
433
+
434
+ Returns:
435
+ Dict with wait results
436
+ """
437
+ return await wait_for_element(selector, state, timeout)
438
+
439
+
440
+
441
+
442
+
443
+ def register_browser_highlight_element(agent):
444
+ """Register the element highlighting tool."""
445
+
446
+ @agent.tool
447
+ async def browser_highlight_element(
448
+ context: RunContext,
449
+ selector: str,
450
+ color: str = "red",
451
+ timeout: int = 10000,
452
+ ) -> Dict[str, Any]:
453
+ """
454
+ Highlight an element with a colored border for visual identification.
455
+
456
+ Args:
457
+ selector: CSS or XPath selector for the element
458
+ color: Highlight color (red, blue, green, yellow, etc.)
459
+ timeout: Timeout in milliseconds
460
+
461
+ Returns:
462
+ Dict with highlight results
463
+ """
464
+ return await highlight_element(selector, color, timeout)
465
+
466
+
467
+ def register_browser_clear_highlights(agent):
468
+ """Register the clear highlights tool."""
469
+
470
+ @agent.tool
471
+ async def browser_clear_highlights(context: RunContext) -> Dict[str, Any]:
472
+ """
473
+ Clear all element highlights from the page.
474
+
475
+ Returns:
476
+ Dict with clear results
477
+ """
478
+ return await clear_highlights()
@@ -0,0 +1,215 @@
1
+ """Browser workflow management tools for saving and reusing automation patterns."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Dict
5
+
6
+ from pydantic_ai import RunContext
7
+
8
+ from code_puppy.messaging import emit_info
9
+ from code_puppy.tools.common import generate_group_id
10
+
11
+
12
+ def get_workflows_directory() -> Path:
13
+ """Get the browser workflows directory, creating it if it doesn't exist."""
14
+ home_dir = Path.home()
15
+ workflows_dir = home_dir / ".code_puppy" / "browser_workflows"
16
+ workflows_dir.mkdir(parents=True, exist_ok=True)
17
+ return workflows_dir
18
+
19
+
20
+ async def save_workflow(name: str, content: str) -> Dict[str, Any]:
21
+ """Save a browser workflow as a markdown file."""
22
+ group_id = generate_group_id("save_workflow", name)
23
+ emit_info(
24
+ f"[bold white on blue] SAVE WORKFLOW [/bold white on blue] 💾 name='{name}'",
25
+ message_group=group_id,
26
+ )
27
+
28
+ try:
29
+ workflows_dir = get_workflows_directory()
30
+
31
+ # Clean up the filename - remove spaces, special chars, etc.
32
+ safe_name = "".join(c for c in name if c.isalnum() or c in ('-', '_')).lower()
33
+ if not safe_name:
34
+ safe_name = "workflow"
35
+
36
+ # Ensure .md extension
37
+ if not safe_name.endswith('.md'):
38
+ safe_name += '.md'
39
+
40
+ workflow_path = workflows_dir / safe_name
41
+
42
+ # Write the workflow content
43
+ with open(workflow_path, 'w', encoding='utf-8') as f:
44
+ f.write(content)
45
+
46
+ emit_info(
47
+ f"[green]✅ Workflow saved successfully: {workflow_path}[/green]",
48
+ message_group=group_id,
49
+ )
50
+
51
+ return {
52
+ "success": True,
53
+ "path": str(workflow_path),
54
+ "name": safe_name,
55
+ "size": len(content)
56
+ }
57
+
58
+ except Exception as e:
59
+ emit_info(
60
+ f"[red]❌ Failed to save workflow: {e}[/red]",
61
+ message_group=group_id,
62
+ )
63
+ return {"success": False, "error": str(e), "name": name}
64
+
65
+
66
+ async def list_workflows() -> Dict[str, Any]:
67
+ """List all available browser workflows."""
68
+ group_id = generate_group_id("list_workflows")
69
+ emit_info(
70
+ "[bold white on blue] LIST WORKFLOWS [/bold white on blue] 📋",
71
+ message_group=group_id,
72
+ )
73
+
74
+ try:
75
+ workflows_dir = get_workflows_directory()
76
+
77
+ # Find all .md files in the workflows directory
78
+ workflow_files = list(workflows_dir.glob('*.md'))
79
+
80
+ workflows = []
81
+ for workflow_file in workflow_files:
82
+ try:
83
+ stat = workflow_file.stat()
84
+ workflows.append({
85
+ "name": workflow_file.name,
86
+ "path": str(workflow_file),
87
+ "size": stat.st_size,
88
+ "modified": stat.st_mtime
89
+ })
90
+ except Exception as e:
91
+ emit_info(f"[yellow]Warning: Could not read {workflow_file}: {e}[/yellow]")
92
+
93
+ # Sort by modification time (newest first)
94
+ workflows.sort(key=lambda x: x['modified'], reverse=True)
95
+
96
+ emit_info(
97
+ f"[green]✅ Found {len(workflows)} workflow(s)[/green]",
98
+ message_group=group_id,
99
+ )
100
+
101
+ return {
102
+ "success": True,
103
+ "workflows": workflows,
104
+ "count": len(workflows),
105
+ "directory": str(workflows_dir)
106
+ }
107
+
108
+ except Exception as e:
109
+ emit_info(
110
+ f"[red]❌ Failed to list workflows: {e}[/red]",
111
+ message_group=group_id,
112
+ )
113
+ return {"success": False, "error": str(e)}
114
+
115
+
116
+ async def read_workflow(name: str) -> Dict[str, Any]:
117
+ """Read a saved browser workflow."""
118
+ group_id = generate_group_id("read_workflow", name)
119
+ emit_info(
120
+ f"[bold white on blue] READ WORKFLOW [/bold white on blue] 📖 name='{name}'",
121
+ message_group=group_id,
122
+ )
123
+
124
+ try:
125
+ workflows_dir = get_workflows_directory()
126
+
127
+ # Handle both with and without .md extension
128
+ if not name.endswith('.md'):
129
+ name += '.md'
130
+
131
+ workflow_path = workflows_dir / name
132
+
133
+ if not workflow_path.exists():
134
+ emit_info(
135
+ f"[red]❌ Workflow not found: {name}[/red]",
136
+ message_group=group_id,
137
+ )
138
+ return {"success": False, "error": f"Workflow '{name}' not found", "name": name}
139
+
140
+ # Read the workflow content
141
+ with open(workflow_path, 'r', encoding='utf-8') as f:
142
+ content = f.read()
143
+
144
+ emit_info(
145
+ f"[green]✅ Workflow read successfully: {len(content)} characters[/green]",
146
+ message_group=group_id,
147
+ )
148
+
149
+ return {
150
+ "success": True,
151
+ "name": name,
152
+ "content": content,
153
+ "path": str(workflow_path),
154
+ "size": len(content)
155
+ }
156
+
157
+ except Exception as e:
158
+ emit_info(
159
+ f"[red]❌ Failed to read workflow: {e}[/red]",
160
+ message_group=group_id,
161
+ )
162
+ return {"success": False, "error": str(e), "name": name}
163
+
164
+
165
+ def register_save_workflow(agent):
166
+ """Register the save workflow tool."""
167
+
168
+ async def save_workflow_tool(
169
+ context: RunContext,
170
+ name: str,
171
+ content: str,
172
+ ) -> Dict[str, Any]:
173
+ """
174
+ Save a browser automation workflow as a markdown file.
175
+
176
+ Args:
177
+ name: Name for the workflow (will be sanitized for filename)
178
+ content: Markdown content describing the workflow steps
179
+
180
+ Returns:
181
+ Dict with success status and file path
182
+ """
183
+ return await save_workflow(name, content)
184
+
185
+
186
+ def register_list_workflows(agent):
187
+ """Register the list workflows tool."""
188
+
189
+ async def list_workflows_tool(context: RunContext) -> Dict[str, Any]:
190
+ """
191
+ List all saved browser automation workflows.
192
+
193
+ Returns:
194
+ Dict with list of available workflows and their metadata
195
+ """
196
+ return await list_workflows()
197
+
198
+
199
+ def register_read_workflow(agent):
200
+ """Register the read workflow tool."""
201
+
202
+ async def read_workflow_tool(
203
+ context: RunContext,
204
+ name: str,
205
+ ) -> Dict[str, Any]:
206
+ """
207
+ Read a saved browser automation workflow.
208
+
209
+ Args:
210
+ name: Name of the workflow to read (with or without .md extension)
211
+
212
+ Returns:
213
+ Dict with workflow content and metadata
214
+ """
215
+ return await read_workflow(name)