flowly-code 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. flowly_code/__init__.py +30 -0
  2. flowly_code/__main__.py +8 -0
  3. flowly_code/activity/__init__.py +1 -0
  4. flowly_code/activity/bus.py +91 -0
  5. flowly_code/activity/events.py +40 -0
  6. flowly_code/agent/__init__.py +8 -0
  7. flowly_code/agent/context.py +485 -0
  8. flowly_code/agent/loop.py +1349 -0
  9. flowly_code/agent/memory.py +109 -0
  10. flowly_code/agent/skills.py +259 -0
  11. flowly_code/agent/subagent.py +249 -0
  12. flowly_code/agent/tools/__init__.py +6 -0
  13. flowly_code/agent/tools/base.py +55 -0
  14. flowly_code/agent/tools/delegate.py +194 -0
  15. flowly_code/agent/tools/dispatch.py +840 -0
  16. flowly_code/agent/tools/docker.py +609 -0
  17. flowly_code/agent/tools/filesystem.py +280 -0
  18. flowly_code/agent/tools/mcp.py +85 -0
  19. flowly_code/agent/tools/message.py +235 -0
  20. flowly_code/agent/tools/registry.py +257 -0
  21. flowly_code/agent/tools/screenshot.py +444 -0
  22. flowly_code/agent/tools/shell.py +166 -0
  23. flowly_code/agent/tools/spawn.py +65 -0
  24. flowly_code/agent/tools/system.py +917 -0
  25. flowly_code/agent/tools/trello.py +420 -0
  26. flowly_code/agent/tools/web.py +139 -0
  27. flowly_code/agent/tools/x.py +399 -0
  28. flowly_code/bus/__init__.py +6 -0
  29. flowly_code/bus/events.py +37 -0
  30. flowly_code/bus/queue.py +81 -0
  31. flowly_code/channels/__init__.py +6 -0
  32. flowly_code/channels/base.py +121 -0
  33. flowly_code/channels/manager.py +135 -0
  34. flowly_code/channels/telegram.py +1132 -0
  35. flowly_code/cli/__init__.py +1 -0
  36. flowly_code/cli/commands.py +1831 -0
  37. flowly_code/cli/setup.py +1356 -0
  38. flowly_code/compaction/__init__.py +39 -0
  39. flowly_code/compaction/estimator.py +88 -0
  40. flowly_code/compaction/pruning.py +223 -0
  41. flowly_code/compaction/service.py +297 -0
  42. flowly_code/compaction/summarizer.py +384 -0
  43. flowly_code/compaction/types.py +71 -0
  44. flowly_code/config/__init__.py +6 -0
  45. flowly_code/config/loader.py +102 -0
  46. flowly_code/config/schema.py +324 -0
  47. flowly_code/exec/__init__.py +39 -0
  48. flowly_code/exec/approvals.py +288 -0
  49. flowly_code/exec/executor.py +184 -0
  50. flowly_code/exec/safety.py +247 -0
  51. flowly_code/exec/types.py +88 -0
  52. flowly_code/gateway/__init__.py +5 -0
  53. flowly_code/gateway/server.py +103 -0
  54. flowly_code/heartbeat/__init__.py +5 -0
  55. flowly_code/heartbeat/service.py +130 -0
  56. flowly_code/multiagent/README.md +248 -0
  57. flowly_code/multiagent/__init__.py +1 -0
  58. flowly_code/multiagent/invoke.py +210 -0
  59. flowly_code/multiagent/orchestrator.py +156 -0
  60. flowly_code/multiagent/router.py +156 -0
  61. flowly_code/multiagent/setup.py +171 -0
  62. flowly_code/pairing/__init__.py +21 -0
  63. flowly_code/pairing/store.py +343 -0
  64. flowly_code/providers/__init__.py +6 -0
  65. flowly_code/providers/base.py +69 -0
  66. flowly_code/providers/litellm_provider.py +178 -0
  67. flowly_code/providers/transcription.py +64 -0
  68. flowly_code/session/__init__.py +5 -0
  69. flowly_code/session/manager.py +249 -0
  70. flowly_code/skills/README.md +24 -0
  71. flowly_code/skills/compact/SKILL.md +27 -0
  72. flowly_code/skills/github/SKILL.md +48 -0
  73. flowly_code/skills/skill-creator/SKILL.md +371 -0
  74. flowly_code/skills/summarize/SKILL.md +67 -0
  75. flowly_code/skills/tmux/SKILL.md +121 -0
  76. flowly_code/skills/tmux/scripts/find-sessions.sh +112 -0
  77. flowly_code/skills/tmux/scripts/wait-for-text.sh +83 -0
  78. flowly_code/skills/weather/SKILL.md +49 -0
  79. flowly_code/utils/__init__.py +5 -0
  80. flowly_code/utils/helpers.py +91 -0
  81. flowly_code-1.0.0.dist-info/METADATA +724 -0
  82. flowly_code-1.0.0.dist-info/RECORD +86 -0
  83. flowly_code-1.0.0.dist-info/WHEEL +4 -0
  84. flowly_code-1.0.0.dist-info/entry_points.txt +2 -0
  85. flowly_code-1.0.0.dist-info/licenses/LICENSE +191 -0
  86. flowly_code-1.0.0.dist-info/licenses/NOTICE +74 -0
@@ -0,0 +1,609 @@
1
+ """Docker integration tool for managing containers, images, and compose stacks."""
2
+
3
+ import asyncio
4
+ import json
5
+ from typing import Any
6
+
7
+ from loguru import logger
8
+
9
+ from flowly_code.agent.tools.base import Tool
10
+
11
+
12
+ class DockerTool(Tool):
13
+ """
14
+ Tool to manage Docker containers, images, volumes, and compose stacks.
15
+
16
+ Requires Docker to be installed and accessible.
17
+ """
18
+
19
+ def __init__(self, timeout: int = 30):
20
+ self.timeout = timeout
21
+
22
+ @property
23
+ def name(self) -> str:
24
+ return "docker"
25
+
26
+ @property
27
+ def description(self) -> str:
28
+ return """Manage Docker containers, images, volumes, and compose stacks.
29
+
30
+ Actions:
31
+ - ps: List running containers (all=true for stopped too)
32
+ - logs: Get container logs (container, tail=100)
33
+ - start: Start a stopped container
34
+ - stop: Stop a running container
35
+ - restart: Restart a container
36
+ - rm: Remove a container (force=true to force)
37
+ - exec: Execute a command in a container
38
+ - images: List images
39
+ - pull: Pull an image
40
+ - stats: Get container resource usage
41
+ - inspect: Get detailed container info
42
+ - compose_up: Start compose stack (path to docker-compose.yml)
43
+ - compose_down: Stop compose stack
44
+ - compose_ps: List compose services
45
+ - compose_logs: Get compose service logs
46
+ - volumes: List volumes
47
+ - networks: List networks
48
+ - prune: Clean up unused resources (type: containers/images/volumes/all)
49
+
50
+ Requires Docker to be installed and the user to have Docker permissions."""
51
+
52
+ @property
53
+ def parameters(self) -> dict[str, Any]:
54
+ return {
55
+ "type": "object",
56
+ "properties": {
57
+ "action": {
58
+ "type": "string",
59
+ "description": "The action to perform",
60
+ "enum": [
61
+ "ps", "logs", "start", "stop", "restart", "rm", "exec",
62
+ "images", "pull", "stats", "inspect",
63
+ "compose_up", "compose_down", "compose_ps", "compose_logs",
64
+ "volumes", "networks", "prune"
65
+ ]
66
+ },
67
+ "container": {
68
+ "type": "string",
69
+ "description": "Container name or ID"
70
+ },
71
+ "image": {
72
+ "type": "string",
73
+ "description": "Image name (for pull)"
74
+ },
75
+ "command": {
76
+ "type": "string",
77
+ "description": "Command to execute (for exec)"
78
+ },
79
+ "path": {
80
+ "type": "string",
81
+ "description": "Path to docker-compose.yml (for compose commands)"
82
+ },
83
+ "service": {
84
+ "type": "string",
85
+ "description": "Service name (for compose_logs)"
86
+ },
87
+ "tail": {
88
+ "type": "integer",
89
+ "description": "Number of log lines to show (default: 100)"
90
+ },
91
+ "all": {
92
+ "type": "boolean",
93
+ "description": "Include stopped containers (for ps)"
94
+ },
95
+ "force": {
96
+ "type": "boolean",
97
+ "description": "Force operation (for rm)"
98
+ },
99
+ "type": {
100
+ "type": "string",
101
+ "description": "Resource type for prune (containers/images/volumes/all)",
102
+ "enum": ["containers", "images", "volumes", "all"]
103
+ },
104
+ "detach": {
105
+ "type": "boolean",
106
+ "description": "Run in detached mode (for compose_up)"
107
+ }
108
+ },
109
+ "required": ["action"]
110
+ }
111
+
112
+ async def _run_command(self, cmd: list[str]) -> tuple[int, str, str]:
113
+ """Run a command and return exit code, stdout, stderr."""
114
+ try:
115
+ proc = await asyncio.create_subprocess_exec(
116
+ *cmd,
117
+ stdout=asyncio.subprocess.PIPE,
118
+ stderr=asyncio.subprocess.PIPE
119
+ )
120
+ stdout, stderr = await asyncio.wait_for(
121
+ proc.communicate(),
122
+ timeout=self.timeout
123
+ )
124
+ max_bytes = 256 * 1024
125
+ out = stdout[:max_bytes].decode(errors="replace")
126
+ err = stderr[:max_bytes].decode(errors="replace")
127
+ return proc.returncode or 0, out, err
128
+ except asyncio.TimeoutError:
129
+ return -1, "", f"Command timed out after {self.timeout}s"
130
+ except FileNotFoundError:
131
+ return -1, "", "Docker not found. Is Docker installed?"
132
+ except Exception as e:
133
+ return -1, "", str(e)
134
+
135
+ async def execute(self, action: str, **kwargs: Any) -> str:
136
+ """Execute a Docker action."""
137
+ try:
138
+ if action == "ps":
139
+ return await self._ps(kwargs.get("all", False))
140
+ elif action == "logs":
141
+ return await self._logs(
142
+ kwargs.get("container", ""),
143
+ kwargs.get("tail", 100)
144
+ )
145
+ elif action == "start":
146
+ return await self._start(kwargs.get("container", ""))
147
+ elif action == "stop":
148
+ return await self._stop(kwargs.get("container", ""))
149
+ elif action == "restart":
150
+ return await self._restart(kwargs.get("container", ""))
151
+ elif action == "rm":
152
+ return await self._rm(
153
+ kwargs.get("container", ""),
154
+ kwargs.get("force", False)
155
+ )
156
+ elif action == "exec":
157
+ return await self._exec(
158
+ kwargs.get("container", ""),
159
+ kwargs.get("command", "")
160
+ )
161
+ elif action == "images":
162
+ return await self._images()
163
+ elif action == "pull":
164
+ return await self._pull(kwargs.get("image", ""))
165
+ elif action == "stats":
166
+ return await self._stats(kwargs.get("container"))
167
+ elif action == "inspect":
168
+ return await self._inspect(kwargs.get("container", ""))
169
+ elif action == "compose_up":
170
+ return await self._compose_up(
171
+ kwargs.get("path", ""),
172
+ kwargs.get("detach", True)
173
+ )
174
+ elif action == "compose_down":
175
+ return await self._compose_down(kwargs.get("path", ""))
176
+ elif action == "compose_ps":
177
+ return await self._compose_ps(kwargs.get("path", ""))
178
+ elif action == "compose_logs":
179
+ return await self._compose_logs(
180
+ kwargs.get("path", ""),
181
+ kwargs.get("service"),
182
+ kwargs.get("tail", 100)
183
+ )
184
+ elif action == "volumes":
185
+ return await self._volumes()
186
+ elif action == "networks":
187
+ return await self._networks()
188
+ elif action == "prune":
189
+ return await self._prune(kwargs.get("type", "all"))
190
+ else:
191
+ return f"Unknown action: {action}"
192
+ except Exception as e:
193
+ logger.error(f"Docker error: {e}")
194
+ return f"Error: {str(e)}"
195
+
196
+ async def _ps(self, all_containers: bool = False) -> str:
197
+ """List containers."""
198
+ cmd = ["docker", "ps", "--format", "json"]
199
+ if all_containers:
200
+ cmd.append("-a")
201
+
202
+ code, stdout, stderr = await self._run_command(cmd)
203
+ if code != 0:
204
+ return f"Error: {stderr}"
205
+
206
+ if not stdout.strip():
207
+ return "No containers found."
208
+
209
+ lines = ["**Docker Containers:**\n"]
210
+ for line in stdout.strip().split("\n"):
211
+ if not line:
212
+ continue
213
+ try:
214
+ c = json.loads(line)
215
+ status_icon = "🟢" if "Up" in c.get("Status", "") else "🔴"
216
+ lines.append(f"{status_icon} **{c.get('Names', 'unknown')}**")
217
+ lines.append(f" Image: {c.get('Image', 'unknown')}")
218
+ lines.append(f" Status: {c.get('Status', 'unknown')}")
219
+ lines.append(f" Ports: {c.get('Ports', '-')}")
220
+ lines.append(f" ID: {c.get('ID', 'unknown')[:12]}")
221
+ lines.append("")
222
+ except json.JSONDecodeError:
223
+ continue
224
+
225
+ return "\n".join(lines)
226
+
227
+ async def _logs(self, container: str, tail: int = 100) -> str:
228
+ """Get container logs."""
229
+ if not container:
230
+ return "Error: container name or ID required"
231
+
232
+ cmd = ["docker", "logs", "--tail", str(tail), container]
233
+ code, stdout, stderr = await self._run_command(cmd)
234
+
235
+ if code != 0:
236
+ return f"Error: {stderr}"
237
+
238
+ output = stdout or stderr # Some apps log to stderr
239
+ if not output.strip():
240
+ return f"No logs found for container '{container}'"
241
+
242
+ # Truncate if too long
243
+ max_chars = 4000
244
+ if len(output) > max_chars:
245
+ output = output[-max_chars:]
246
+ output = f"...(truncated)\n{output}"
247
+
248
+ return f"**Logs for {container}** (last {tail} lines):\n```\n{output}\n```"
249
+
250
+ async def _start(self, container: str) -> str:
251
+ """Start a container."""
252
+ if not container:
253
+ return "Error: container name or ID required"
254
+
255
+ cmd = ["docker", "start", container]
256
+ code, stdout, stderr = await self._run_command(cmd)
257
+
258
+ if code != 0:
259
+ return f"Error: {stderr}"
260
+ return f"Started container: {container}"
261
+
262
+ async def _stop(self, container: str) -> str:
263
+ """Stop a container."""
264
+ if not container:
265
+ return "Error: container name or ID required"
266
+
267
+ cmd = ["docker", "stop", container]
268
+ code, stdout, stderr = await self._run_command(cmd)
269
+
270
+ if code != 0:
271
+ return f"Error: {stderr}"
272
+ return f"Stopped container: {container}"
273
+
274
+ async def _restart(self, container: str) -> str:
275
+ """Restart a container."""
276
+ if not container:
277
+ return "Error: container name or ID required"
278
+
279
+ cmd = ["docker", "restart", container]
280
+ code, stdout, stderr = await self._run_command(cmd)
281
+
282
+ if code != 0:
283
+ return f"Error: {stderr}"
284
+ return f"Restarted container: {container}"
285
+
286
+ async def _rm(self, container: str, force: bool = False) -> str:
287
+ """Remove a container."""
288
+ if not container:
289
+ return "Error: container name or ID required"
290
+
291
+ cmd = ["docker", "rm", container]
292
+ if force:
293
+ cmd.insert(2, "-f")
294
+
295
+ code, stdout, stderr = await self._run_command(cmd)
296
+
297
+ if code != 0:
298
+ return f"Error: {stderr}"
299
+ return f"Removed container: {container}"
300
+
301
+ async def _exec(self, container: str, command: str) -> str:
302
+ """Execute a command in a container."""
303
+ if not container:
304
+ return "Error: container name or ID required"
305
+ if not command:
306
+ return "Error: command required"
307
+
308
+ # Use shlex for proper shell quoting support
309
+ import shlex
310
+ cmd = ["docker", "exec", container] + shlex.split(command)
311
+ code, stdout, stderr = await self._run_command(cmd)
312
+
313
+ output = stdout or stderr
314
+ if code != 0:
315
+ return f"Error (exit {code}): {output}"
316
+
317
+ if not output.strip():
318
+ return "(no output)"
319
+ return f"```\n{output}\n```"
320
+
321
+ async def _images(self) -> str:
322
+ """List images."""
323
+ cmd = ["docker", "images", "--format", "json"]
324
+ code, stdout, stderr = await self._run_command(cmd)
325
+
326
+ if code != 0:
327
+ return f"Error: {stderr}"
328
+
329
+ if not stdout.strip():
330
+ return "No images found."
331
+
332
+ lines = ["**Docker Images:**\n"]
333
+ for line in stdout.strip().split("\n"):
334
+ if not line:
335
+ continue
336
+ try:
337
+ img = json.loads(line)
338
+ repo = img.get("Repository", "unknown")
339
+ tag = img.get("Tag", "latest")
340
+ size = img.get("Size", "unknown")
341
+ lines.append(f"- **{repo}:{tag}** ({size})")
342
+ except json.JSONDecodeError:
343
+ continue
344
+
345
+ return "\n".join(lines)
346
+
347
+ async def _pull(self, image: str) -> str:
348
+ """Pull an image."""
349
+ if not image:
350
+ return "Error: image name required"
351
+
352
+ cmd = ["docker", "pull", image]
353
+ code, stdout, stderr = await self._run_command(cmd)
354
+
355
+ if code != 0:
356
+ return f"Error: {stderr}"
357
+ return f"Pulled image: {image}"
358
+
359
+ async def _stats(self, container: str | None = None) -> str:
360
+ """Get container stats."""
361
+ cmd = ["docker", "stats", "--no-stream", "--format", "json"]
362
+ if container:
363
+ cmd.append(container)
364
+
365
+ code, stdout, stderr = await self._run_command(cmd)
366
+
367
+ if code != 0:
368
+ return f"Error: {stderr}"
369
+
370
+ if not stdout.strip():
371
+ return "No running containers."
372
+
373
+ lines = ["**Container Stats:**\n"]
374
+ lines.append("| Container | CPU | Memory | Net I/O |")
375
+ lines.append("|-----------|-----|--------|---------|")
376
+
377
+ for line in stdout.strip().split("\n"):
378
+ if not line:
379
+ continue
380
+ try:
381
+ s = json.loads(line)
382
+ name = s.get("Name", "unknown")[:15]
383
+ cpu = s.get("CPUPerc", "0%")
384
+ mem = s.get("MemPerc", "0%")
385
+ net = s.get("NetIO", "0B/0B")
386
+ lines.append(f"| {name} | {cpu} | {mem} | {net} |")
387
+ except json.JSONDecodeError:
388
+ continue
389
+
390
+ return "\n".join(lines)
391
+
392
+ async def _inspect(self, container: str) -> str:
393
+ """Inspect a container."""
394
+ if not container:
395
+ return "Error: container name or ID required"
396
+
397
+ cmd = ["docker", "inspect", container]
398
+ code, stdout, stderr = await self._run_command(cmd)
399
+
400
+ if code != 0:
401
+ return f"Error: {stderr}"
402
+
403
+ try:
404
+ data = json.loads(stdout)
405
+ if not data:
406
+ return f"Container '{container}' not found"
407
+
408
+ c = data[0]
409
+ state = c.get("State", {})
410
+ config = c.get("Config", {})
411
+ network = c.get("NetworkSettings", {})
412
+
413
+ lines = [f"**Container: {container}**\n"]
414
+ lines.append(f"**ID:** {c.get('Id', 'unknown')[:12]}")
415
+ lines.append(f"**Image:** {config.get('Image', 'unknown')}")
416
+ lines.append(f"**Status:** {state.get('Status', 'unknown')}")
417
+ lines.append(f"**Running:** {state.get('Running', False)}")
418
+ lines.append(f"**Started:** {state.get('StartedAt', 'unknown')}")
419
+
420
+ # Environment variables (filtered)
421
+ env = config.get("Env", [])
422
+ safe_env = [e for e in env if not any(s in e.lower() for s in ["password", "secret", "key", "token"])]
423
+ if safe_env:
424
+ lines.append(f"\n**Environment:**")
425
+ for e in safe_env[:10]:
426
+ lines.append(f" - {e}")
427
+
428
+ # Ports
429
+ ports = network.get("Ports", {})
430
+ if ports:
431
+ lines.append(f"\n**Ports:**")
432
+ for port, bindings in ports.items():
433
+ if bindings:
434
+ for b in bindings:
435
+ lines.append(f" - {b.get('HostPort', '?')} -> {port}")
436
+
437
+ # Mounts
438
+ mounts = c.get("Mounts", [])
439
+ if mounts:
440
+ lines.append(f"\n**Mounts:**")
441
+ for m in mounts[:5]:
442
+ lines.append(f" - {m.get('Source', '?')} -> {m.get('Destination', '?')}")
443
+
444
+ return "\n".join(lines)
445
+ except json.JSONDecodeError:
446
+ return f"Error parsing container info"
447
+
448
+ async def _compose_up(self, path: str, detach: bool = True) -> str:
449
+ """Start a compose stack."""
450
+ if not path:
451
+ return "Error: path to docker-compose.yml required"
452
+
453
+ cmd = ["docker", "compose", "-f", path, "up"]
454
+ if detach:
455
+ cmd.append("-d")
456
+
457
+ code, stdout, stderr = await self._run_command(cmd)
458
+
459
+ if code != 0:
460
+ return f"Error: {stderr}"
461
+ return f"Started compose stack: {path}\n{stdout}"
462
+
463
+ async def _compose_down(self, path: str) -> str:
464
+ """Stop a compose stack."""
465
+ if not path:
466
+ return "Error: path to docker-compose.yml required"
467
+
468
+ cmd = ["docker", "compose", "-f", path, "down"]
469
+ code, stdout, stderr = await self._run_command(cmd)
470
+
471
+ if code != 0:
472
+ return f"Error: {stderr}"
473
+ return f"Stopped compose stack: {path}"
474
+
475
+ async def _compose_ps(self, path: str) -> str:
476
+ """List compose services."""
477
+ if not path:
478
+ return "Error: path to docker-compose.yml required"
479
+
480
+ cmd = ["docker", "compose", "-f", path, "ps", "--format", "json"]
481
+ code, stdout, stderr = await self._run_command(cmd)
482
+
483
+ if code != 0:
484
+ return f"Error: {stderr}"
485
+
486
+ if not stdout.strip():
487
+ return "No services found."
488
+
489
+ lines = [f"**Compose Services ({path}):**\n"]
490
+ for line in stdout.strip().split("\n"):
491
+ if not line:
492
+ continue
493
+ try:
494
+ s = json.loads(line)
495
+ status_icon = "🟢" if s.get("State") == "running" else "🔴"
496
+ lines.append(f"{status_icon} **{s.get('Service', 'unknown')}**")
497
+ lines.append(f" Status: {s.get('State', 'unknown')}")
498
+ lines.append(f" Ports: {s.get('Publishers', [])}")
499
+ lines.append("")
500
+ except json.JSONDecodeError:
501
+ continue
502
+
503
+ return "\n".join(lines)
504
+
505
+ async def _compose_logs(self, path: str, service: str | None, tail: int = 100) -> str:
506
+ """Get compose service logs."""
507
+ if not path:
508
+ return "Error: path to docker-compose.yml required"
509
+
510
+ cmd = ["docker", "compose", "-f", path, "logs", "--tail", str(tail)]
511
+ if service:
512
+ cmd.append(service)
513
+
514
+ code, stdout, stderr = await self._run_command(cmd)
515
+
516
+ output = stdout or stderr
517
+ if code != 0:
518
+ return f"Error: {output}"
519
+
520
+ if not output.strip():
521
+ return "No logs found."
522
+
523
+ # Truncate if too long
524
+ max_chars = 4000
525
+ if len(output) > max_chars:
526
+ output = output[-max_chars:]
527
+ output = f"...(truncated)\n{output}"
528
+
529
+ service_info = f" ({service})" if service else ""
530
+ return f"**Compose Logs{service_info}:**\n```\n{output}\n```"
531
+
532
+ async def _volumes(self) -> str:
533
+ """List volumes."""
534
+ cmd = ["docker", "volume", "ls", "--format", "json"]
535
+ code, stdout, stderr = await self._run_command(cmd)
536
+
537
+ if code != 0:
538
+ return f"Error: {stderr}"
539
+
540
+ if not stdout.strip():
541
+ return "No volumes found."
542
+
543
+ lines = ["**Docker Volumes:**\n"]
544
+ for line in stdout.strip().split("\n"):
545
+ if not line:
546
+ continue
547
+ try:
548
+ v = json.loads(line)
549
+ lines.append(f"- **{v.get('Name', 'unknown')}** (Driver: {v.get('Driver', 'local')})")
550
+ except json.JSONDecodeError:
551
+ continue
552
+
553
+ return "\n".join(lines)
554
+
555
+ async def _networks(self) -> str:
556
+ """List networks."""
557
+ cmd = ["docker", "network", "ls", "--format", "json"]
558
+ code, stdout, stderr = await self._run_command(cmd)
559
+
560
+ if code != 0:
561
+ return f"Error: {stderr}"
562
+
563
+ if not stdout.strip():
564
+ return "No networks found."
565
+
566
+ lines = ["**Docker Networks:**\n"]
567
+ for line in stdout.strip().split("\n"):
568
+ if not line:
569
+ continue
570
+ try:
571
+ n = json.loads(line)
572
+ lines.append(f"- **{n.get('Name', 'unknown')}** ({n.get('Driver', 'bridge')})")
573
+ except json.JSONDecodeError:
574
+ continue
575
+
576
+ return "\n".join(lines)
577
+
578
+ async def _prune(self, resource_type: str = "all") -> str:
579
+ """Clean up unused resources."""
580
+ results = []
581
+
582
+ if resource_type in ("containers", "all"):
583
+ cmd = ["docker", "container", "prune", "-f"]
584
+ code, stdout, stderr = await self._run_command(cmd)
585
+ if code == 0:
586
+ results.append(f"Containers: {stdout.strip()}")
587
+
588
+ if resource_type in ("images", "all"):
589
+ cmd = ["docker", "image", "prune", "-f"]
590
+ code, stdout, stderr = await self._run_command(cmd)
591
+ if code == 0:
592
+ results.append(f"Images: {stdout.strip()}")
593
+
594
+ if resource_type in ("volumes", "all"):
595
+ cmd = ["docker", "volume", "prune", "-f"]
596
+ code, stdout, stderr = await self._run_command(cmd)
597
+ if code == 0:
598
+ results.append(f"Volumes: {stdout.strip()}")
599
+
600
+ if resource_type == "all":
601
+ cmd = ["docker", "network", "prune", "-f"]
602
+ code, stdout, stderr = await self._run_command(cmd)
603
+ if code == 0:
604
+ results.append(f"Networks: {stdout.strip()}")
605
+
606
+ if not results:
607
+ return "Nothing to clean up."
608
+
609
+ return "**Cleanup Results:**\n" + "\n".join(results)