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.
- flowly_code/__init__.py +30 -0
- flowly_code/__main__.py +8 -0
- flowly_code/activity/__init__.py +1 -0
- flowly_code/activity/bus.py +91 -0
- flowly_code/activity/events.py +40 -0
- flowly_code/agent/__init__.py +8 -0
- flowly_code/agent/context.py +485 -0
- flowly_code/agent/loop.py +1349 -0
- flowly_code/agent/memory.py +109 -0
- flowly_code/agent/skills.py +259 -0
- flowly_code/agent/subagent.py +249 -0
- flowly_code/agent/tools/__init__.py +6 -0
- flowly_code/agent/tools/base.py +55 -0
- flowly_code/agent/tools/delegate.py +194 -0
- flowly_code/agent/tools/dispatch.py +840 -0
- flowly_code/agent/tools/docker.py +609 -0
- flowly_code/agent/tools/filesystem.py +280 -0
- flowly_code/agent/tools/mcp.py +85 -0
- flowly_code/agent/tools/message.py +235 -0
- flowly_code/agent/tools/registry.py +257 -0
- flowly_code/agent/tools/screenshot.py +444 -0
- flowly_code/agent/tools/shell.py +166 -0
- flowly_code/agent/tools/spawn.py +65 -0
- flowly_code/agent/tools/system.py +917 -0
- flowly_code/agent/tools/trello.py +420 -0
- flowly_code/agent/tools/web.py +139 -0
- flowly_code/agent/tools/x.py +399 -0
- flowly_code/bus/__init__.py +6 -0
- flowly_code/bus/events.py +37 -0
- flowly_code/bus/queue.py +81 -0
- flowly_code/channels/__init__.py +6 -0
- flowly_code/channels/base.py +121 -0
- flowly_code/channels/manager.py +135 -0
- flowly_code/channels/telegram.py +1132 -0
- flowly_code/cli/__init__.py +1 -0
- flowly_code/cli/commands.py +1831 -0
- flowly_code/cli/setup.py +1356 -0
- flowly_code/compaction/__init__.py +39 -0
- flowly_code/compaction/estimator.py +88 -0
- flowly_code/compaction/pruning.py +223 -0
- flowly_code/compaction/service.py +297 -0
- flowly_code/compaction/summarizer.py +384 -0
- flowly_code/compaction/types.py +71 -0
- flowly_code/config/__init__.py +6 -0
- flowly_code/config/loader.py +102 -0
- flowly_code/config/schema.py +324 -0
- flowly_code/exec/__init__.py +39 -0
- flowly_code/exec/approvals.py +288 -0
- flowly_code/exec/executor.py +184 -0
- flowly_code/exec/safety.py +247 -0
- flowly_code/exec/types.py +88 -0
- flowly_code/gateway/__init__.py +5 -0
- flowly_code/gateway/server.py +103 -0
- flowly_code/heartbeat/__init__.py +5 -0
- flowly_code/heartbeat/service.py +130 -0
- flowly_code/multiagent/README.md +248 -0
- flowly_code/multiagent/__init__.py +1 -0
- flowly_code/multiagent/invoke.py +210 -0
- flowly_code/multiagent/orchestrator.py +156 -0
- flowly_code/multiagent/router.py +156 -0
- flowly_code/multiagent/setup.py +171 -0
- flowly_code/pairing/__init__.py +21 -0
- flowly_code/pairing/store.py +343 -0
- flowly_code/providers/__init__.py +6 -0
- flowly_code/providers/base.py +69 -0
- flowly_code/providers/litellm_provider.py +178 -0
- flowly_code/providers/transcription.py +64 -0
- flowly_code/session/__init__.py +5 -0
- flowly_code/session/manager.py +249 -0
- flowly_code/skills/README.md +24 -0
- flowly_code/skills/compact/SKILL.md +27 -0
- flowly_code/skills/github/SKILL.md +48 -0
- flowly_code/skills/skill-creator/SKILL.md +371 -0
- flowly_code/skills/summarize/SKILL.md +67 -0
- flowly_code/skills/tmux/SKILL.md +121 -0
- flowly_code/skills/tmux/scripts/find-sessions.sh +112 -0
- flowly_code/skills/tmux/scripts/wait-for-text.sh +83 -0
- flowly_code/skills/weather/SKILL.md +49 -0
- flowly_code/utils/__init__.py +5 -0
- flowly_code/utils/helpers.py +91 -0
- flowly_code-1.0.0.dist-info/METADATA +724 -0
- flowly_code-1.0.0.dist-info/RECORD +86 -0
- flowly_code-1.0.0.dist-info/WHEEL +4 -0
- flowly_code-1.0.0.dist-info/entry_points.txt +2 -0
- flowly_code-1.0.0.dist-info/licenses/LICENSE +191 -0
- 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)
|