linux-mcp-server 0.1.0.dev0__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.
- linux_mcp_server/__init__.py +4 -0
- linux_mcp_server/__main__.py +30 -0
- linux_mcp_server/audit.py +285 -0
- linux_mcp_server/logging_config.py +144 -0
- linux_mcp_server/server.py +328 -0
- linux_mcp_server/tools/__init__.py +2 -0
- linux_mcp_server/tools/logs.py +225 -0
- linux_mcp_server/tools/network.py +281 -0
- linux_mcp_server/tools/processes.py +257 -0
- linux_mcp_server/tools/services.py +147 -0
- linux_mcp_server/tools/ssh_executor.py +324 -0
- linux_mcp_server/tools/storage.py +358 -0
- linux_mcp_server/tools/system_info.py +507 -0
- linux_mcp_server/tools/utils.py +27 -0
- linux_mcp_server/tools/validation.py +58 -0
- linux_mcp_server-0.1.0.dev0.dist-info/METADATA +331 -0
- linux_mcp_server-0.1.0.dev0.dist-info/RECORD +20 -0
- linux_mcp_server-0.1.0.dev0.dist-info/WHEEL +4 -0
- linux_mcp_server-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- linux_mcp_server-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""SSH executor for remote command execution.
|
|
2
|
+
|
|
3
|
+
This module provides functionality to execute commands on remote systems via SSH,
|
|
4
|
+
with connection pooling and SSH key discovery. It seamlessly routes commands to
|
|
5
|
+
either local or remote execution based on the provided parameters.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import shlex
|
|
12
|
+
import subprocess
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional, Tuple
|
|
16
|
+
|
|
17
|
+
import asyncssh
|
|
18
|
+
|
|
19
|
+
from ..audit import log_ssh_connect, log_ssh_command
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def discover_ssh_key() -> Optional[str]:
|
|
26
|
+
"""
|
|
27
|
+
Discover SSH private key for authentication.
|
|
28
|
+
|
|
29
|
+
Checks in order:
|
|
30
|
+
1. LINUX_MCP_SSH_KEY_PATH environment variable
|
|
31
|
+
2. Default locations: ~/.ssh/id_ed25519, ~/.ssh/id_rsa, ~/.ssh/id_ecdsa
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Path to SSH private key if found, None otherwise.
|
|
35
|
+
"""
|
|
36
|
+
logger.debug("Discovering SSH key for authentication")
|
|
37
|
+
|
|
38
|
+
# Check environment variable first
|
|
39
|
+
env_key = os.getenv("LINUX_MCP_SSH_KEY_PATH")
|
|
40
|
+
if env_key:
|
|
41
|
+
logger.debug(f"Checking SSH key from environment: {env_key}")
|
|
42
|
+
key_path = Path(env_key)
|
|
43
|
+
if key_path.exists() and key_path.is_file():
|
|
44
|
+
logger.info(f"Using SSH key from environment: {env_key}")
|
|
45
|
+
return str(key_path)
|
|
46
|
+
else:
|
|
47
|
+
logger.warning(f"SSH key specified in LINUX_MCP_SSH_KEY_PATH not found: {env_key}")
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
# Check default locations (prefer modern algorithms)
|
|
51
|
+
home = Path.home()
|
|
52
|
+
default_keys = [
|
|
53
|
+
home / ".ssh" / "id_ed25519",
|
|
54
|
+
home / ".ssh" / "id_ecdsa",
|
|
55
|
+
home / ".ssh" / "id_rsa",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
logger.debug(f"Checking default SSH key locations: {[str(k) for k in default_keys]}")
|
|
59
|
+
|
|
60
|
+
for key_path in default_keys:
|
|
61
|
+
if key_path.exists() and key_path.is_file():
|
|
62
|
+
logger.info(f"Using SSH key: {key_path}")
|
|
63
|
+
return str(key_path)
|
|
64
|
+
|
|
65
|
+
logger.warning("No SSH private key found in default locations")
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SSHConnectionManager:
|
|
70
|
+
"""
|
|
71
|
+
Manages SSH connections with connection pooling.
|
|
72
|
+
|
|
73
|
+
This class implements a singleton pattern to maintain a pool of SSH connections
|
|
74
|
+
across the lifetime of the application, improving performance by reusing
|
|
75
|
+
connections to the same hosts.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
_instance = None
|
|
79
|
+
|
|
80
|
+
def __new__(cls):
|
|
81
|
+
"""Implement singleton pattern."""
|
|
82
|
+
if cls._instance is None:
|
|
83
|
+
cls._instance = super().__new__(cls)
|
|
84
|
+
cls._instance._connections = {}
|
|
85
|
+
cls._instance._ssh_key = discover_ssh_key()
|
|
86
|
+
return cls._instance
|
|
87
|
+
|
|
88
|
+
async def get_connection(self, host: str, username: str) -> asyncssh.SSHClientConnection:
|
|
89
|
+
"""
|
|
90
|
+
Get or create an SSH connection to a host.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
host: Remote host address
|
|
94
|
+
username: SSH username
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
SSH connection object
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
ConnectionError: If connection fails
|
|
101
|
+
"""
|
|
102
|
+
key = f"{username}@{host}"
|
|
103
|
+
|
|
104
|
+
# Return existing connection if available
|
|
105
|
+
if key in self._connections:
|
|
106
|
+
conn = self._connections[key]
|
|
107
|
+
if not conn.is_closed():
|
|
108
|
+
# DEBUG level: Log connection reuse and pool state
|
|
109
|
+
logger.debug(f"SSH_REUSE: {key} | pool_size={len(self._connections)}")
|
|
110
|
+
# Use audit log with connection reuse info
|
|
111
|
+
log_ssh_connect(host, username, status="success", reused=True, key_path=self._ssh_key)
|
|
112
|
+
return conn
|
|
113
|
+
else:
|
|
114
|
+
# Connection was closed, remove it
|
|
115
|
+
logger.debug(f"SSH_POOL: remove_closed_connection | connection={key}")
|
|
116
|
+
del self._connections[key]
|
|
117
|
+
|
|
118
|
+
# Create new connection
|
|
119
|
+
# DEBUG level: Log connection attempt before it completes
|
|
120
|
+
logger.debug(f"SSH_CONNECTING: {key} | key={self._ssh_key or 'none'}")
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
connect_kwargs = {
|
|
124
|
+
"host": host,
|
|
125
|
+
"username": username,
|
|
126
|
+
"known_hosts": None, # Don't verify host keys for now
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if self._ssh_key:
|
|
130
|
+
connect_kwargs["client_keys"] = [self._ssh_key]
|
|
131
|
+
|
|
132
|
+
conn = await asyncssh.connect(**connect_kwargs)
|
|
133
|
+
self._connections[key] = conn
|
|
134
|
+
|
|
135
|
+
# Log successful connection using audit function
|
|
136
|
+
log_ssh_connect(host, username, status="success", reused=False, key_path=self._ssh_key)
|
|
137
|
+
|
|
138
|
+
# DEBUG level: Log pool state
|
|
139
|
+
logger.debug(f"SSH_POOL: add_connection | connections={len(self._connections)}")
|
|
140
|
+
|
|
141
|
+
return conn
|
|
142
|
+
|
|
143
|
+
except asyncssh.PermissionDenied as e:
|
|
144
|
+
# Use audit log for authentication failure
|
|
145
|
+
error_msg = str(e)
|
|
146
|
+
log_ssh_connect(host, username, status="failed", error=f"Permission denied: {error_msg}")
|
|
147
|
+
raise ConnectionError(f"Authentication failed for {username}@{host}") from e
|
|
148
|
+
except asyncssh.Error as e:
|
|
149
|
+
# Use audit log for connection failure
|
|
150
|
+
error_msg = str(e)
|
|
151
|
+
log_ssh_connect(host, username, status="failed", error=error_msg)
|
|
152
|
+
raise ConnectionError(f"Failed to connect to {username}@{host}: {e}") from e
|
|
153
|
+
|
|
154
|
+
async def execute_remote(
|
|
155
|
+
self,
|
|
156
|
+
command: list[str],
|
|
157
|
+
host: str,
|
|
158
|
+
username: str
|
|
159
|
+
) -> Tuple[int, str, str]:
|
|
160
|
+
"""
|
|
161
|
+
Execute a command on a remote host via SSH.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
command: Command and arguments to execute
|
|
165
|
+
host: Remote host address
|
|
166
|
+
username: SSH username
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Tuple of (return_code, stdout, stderr)
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
ConnectionError: If SSH connection fails
|
|
173
|
+
"""
|
|
174
|
+
conn = await self.get_connection(host, username)
|
|
175
|
+
|
|
176
|
+
# Build command string with proper shell escaping
|
|
177
|
+
# Use shlex.quote() to ensure special characters (like \n in printf format) are preserved
|
|
178
|
+
cmd_str = " ".join(shlex.quote(arg) for arg in command)
|
|
179
|
+
|
|
180
|
+
# Start timing for command execution
|
|
181
|
+
start_time = time.time()
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
result = await conn.run(cmd_str, check=False)
|
|
185
|
+
|
|
186
|
+
return_code = result.exit_status if result.exit_status is not None else 0
|
|
187
|
+
stdout = result.stdout if result.stdout else ""
|
|
188
|
+
stderr = result.stderr if result.stderr else ""
|
|
189
|
+
|
|
190
|
+
# Calculate duration
|
|
191
|
+
duration = time.time() - start_time
|
|
192
|
+
|
|
193
|
+
# Use audit log for command execution
|
|
194
|
+
log_ssh_command(cmd_str, host, exit_code=return_code, duration=duration)
|
|
195
|
+
|
|
196
|
+
return return_code, stdout, stderr
|
|
197
|
+
|
|
198
|
+
except asyncssh.Error as e:
|
|
199
|
+
duration = time.time() - start_time
|
|
200
|
+
logger.error(f"Error executing command on {username}@{host}: {e}", extra={
|
|
201
|
+
'event': 'REMOTE_EXEC_ERROR',
|
|
202
|
+
'command': cmd_str,
|
|
203
|
+
'host': host,
|
|
204
|
+
'duration': f"{duration:.3f}s",
|
|
205
|
+
'error': str(e)
|
|
206
|
+
})
|
|
207
|
+
raise ConnectionError(f"Failed to execute command on {username}@{host}: {e}") from e
|
|
208
|
+
|
|
209
|
+
async def close_all(self):
|
|
210
|
+
"""Close all SSH connections."""
|
|
211
|
+
connection_count = len(self._connections)
|
|
212
|
+
logger.info(f"Closing {connection_count} SSH connections")
|
|
213
|
+
|
|
214
|
+
for key, conn in list(self._connections.items()):
|
|
215
|
+
try:
|
|
216
|
+
logger.debug(f"SSH_CLOSE: {key}")
|
|
217
|
+
conn.close()
|
|
218
|
+
await conn.wait_closed()
|
|
219
|
+
except Exception as e:
|
|
220
|
+
logger.warning(f"Error closing connection to {key}: {e}")
|
|
221
|
+
|
|
222
|
+
self._connections.clear()
|
|
223
|
+
logger.debug(f"SSH_POOL: cleared | closed_connections={connection_count}")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# Global connection manager instance
|
|
227
|
+
_connection_manager = SSHConnectionManager()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
async def execute_command(
|
|
231
|
+
command: list[str],
|
|
232
|
+
host: Optional[str] = None,
|
|
233
|
+
username: Optional[str] = None,
|
|
234
|
+
**kwargs
|
|
235
|
+
) -> Tuple[int, str, str]:
|
|
236
|
+
"""
|
|
237
|
+
Execute a command locally or remotely.
|
|
238
|
+
|
|
239
|
+
This is the main entry point for command execution. It routes the command
|
|
240
|
+
to either local subprocess execution or remote SSH execution based on
|
|
241
|
+
whether host/username parameters are provided.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
command: Command and arguments to execute
|
|
245
|
+
host: Optional remote host address
|
|
246
|
+
username: Optional SSH username (required if host is provided)
|
|
247
|
+
**kwargs: Additional arguments (reserved for future use)
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Tuple of (return_code, stdout, stderr)
|
|
251
|
+
|
|
252
|
+
Raises:
|
|
253
|
+
ValueError: If host is provided without username
|
|
254
|
+
ConnectionError: If remote connection fails
|
|
255
|
+
|
|
256
|
+
Examples:
|
|
257
|
+
# Local execution
|
|
258
|
+
>>> returncode, stdout, stderr = await execute_command(["ls", "-la"])
|
|
259
|
+
|
|
260
|
+
# Remote execution
|
|
261
|
+
>>> returncode, stdout, stderr = await execute_command(
|
|
262
|
+
... ["ls", "-la"],
|
|
263
|
+
... host="server.example.com",
|
|
264
|
+
... username="admin"
|
|
265
|
+
... )
|
|
266
|
+
"""
|
|
267
|
+
cmd_str = " ".join(command)
|
|
268
|
+
|
|
269
|
+
# Route to remote execution if host is provided
|
|
270
|
+
if host:
|
|
271
|
+
if not username:
|
|
272
|
+
logger.error(f"Host provided without username for command: {cmd_str}")
|
|
273
|
+
raise ValueError("username is required when host is provided")
|
|
274
|
+
|
|
275
|
+
logger.debug(f"Routing to remote execution: {username}@{host} | command={cmd_str}")
|
|
276
|
+
return await _connection_manager.execute_remote(command, host, username)
|
|
277
|
+
|
|
278
|
+
# Local execution
|
|
279
|
+
logger.debug(f"LOCAL_EXEC: {cmd_str}")
|
|
280
|
+
return await _execute_local(command)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
async def _execute_local(command: list[str]) -> Tuple[int, str, str]:
|
|
284
|
+
"""
|
|
285
|
+
Execute a command locally using subprocess.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
command: Command and arguments to execute
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
Tuple of (return_code, stdout, stderr)
|
|
292
|
+
"""
|
|
293
|
+
cmd_str = " ".join(command)
|
|
294
|
+
start_time = time.time()
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
proc = await asyncio.create_subprocess_exec(
|
|
298
|
+
*command,
|
|
299
|
+
stdout=subprocess.PIPE,
|
|
300
|
+
stderr=subprocess.PIPE
|
|
301
|
+
)
|
|
302
|
+
stdout_bytes, stderr_bytes = await proc.communicate()
|
|
303
|
+
|
|
304
|
+
return_code = proc.returncode if proc.returncode is not None else 0
|
|
305
|
+
stdout = stdout_bytes.decode('utf-8', errors='replace')
|
|
306
|
+
stderr = stderr_bytes.decode('utf-8', errors='replace')
|
|
307
|
+
|
|
308
|
+
duration = time.time() - start_time
|
|
309
|
+
|
|
310
|
+
# DEBUG level: Log local command execution with timing
|
|
311
|
+
logger.debug(f"LOCAL_EXEC completed: {cmd_str} | exit_code={return_code} | duration={duration:.3f}s")
|
|
312
|
+
|
|
313
|
+
return return_code, stdout, stderr
|
|
314
|
+
|
|
315
|
+
except Exception as e:
|
|
316
|
+
duration = time.time() - start_time
|
|
317
|
+
logger.error(f"Error executing local command: {cmd_str}", extra={
|
|
318
|
+
'event': 'LOCAL_EXEC_ERROR',
|
|
319
|
+
'command': cmd_str,
|
|
320
|
+
'duration': f"{duration:.3f}s",
|
|
321
|
+
'error': str(e)
|
|
322
|
+
})
|
|
323
|
+
return 1, "", str(e)
|
|
324
|
+
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Storage and hardware tools."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
import psutil
|
|
8
|
+
|
|
9
|
+
from .validation import validate_positive_int
|
|
10
|
+
from .ssh_executor import execute_command
|
|
11
|
+
from .utils import format_bytes
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def list_block_devices(host: Optional[str] = None, username: Optional[str] = None) -> str:
|
|
15
|
+
"""
|
|
16
|
+
List block devices.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
host: Optional remote host to connect to
|
|
20
|
+
username: Optional SSH username (required if host is provided)
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Formatted string with block device information
|
|
24
|
+
"""
|
|
25
|
+
try:
|
|
26
|
+
# Try using lsblk first (most readable)
|
|
27
|
+
returncode, stdout, stderr = await execute_command(
|
|
28
|
+
["lsblk", "-o", "NAME,SIZE,TYPE,MOUNTPOINT,FSTYPE,MODEL", "--no-pager"],
|
|
29
|
+
host=host,
|
|
30
|
+
username=username
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if returncode == 0:
|
|
34
|
+
result = ["=== Block Devices ===\n"]
|
|
35
|
+
result.append(stdout)
|
|
36
|
+
|
|
37
|
+
# Add disk I/O per-disk stats if available (only for local execution)
|
|
38
|
+
if not host:
|
|
39
|
+
try:
|
|
40
|
+
disk_io_per_disk = psutil.disk_io_counters(perdisk=True)
|
|
41
|
+
if disk_io_per_disk:
|
|
42
|
+
result.append("\n=== Disk I/O Statistics (per disk) ===")
|
|
43
|
+
for disk, stats in sorted(disk_io_per_disk.items()):
|
|
44
|
+
result.append(f"\n{disk}:")
|
|
45
|
+
result.append(f" Read: {format_bytes(stats.read_bytes)}")
|
|
46
|
+
result.append(f" Write: {format_bytes(stats.write_bytes)}")
|
|
47
|
+
result.append(f" Read Count: {stats.read_count}")
|
|
48
|
+
result.append(f" Write Count: {stats.write_count}")
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
return "\n".join(result)
|
|
53
|
+
else:
|
|
54
|
+
# Fallback to listing partitions with psutil
|
|
55
|
+
result = ["=== Block Devices (fallback) ===\n"]
|
|
56
|
+
partitions = psutil.disk_partitions(all=True)
|
|
57
|
+
|
|
58
|
+
for partition in partitions:
|
|
59
|
+
result.append(f"\nDevice: {partition.device}")
|
|
60
|
+
result.append(f" Mountpoint: {partition.mountpoint}")
|
|
61
|
+
result.append(f" Filesystem: {partition.fstype}")
|
|
62
|
+
result.append(f" Options: {partition.opts}")
|
|
63
|
+
|
|
64
|
+
return "\n".join(result)
|
|
65
|
+
except FileNotFoundError:
|
|
66
|
+
# If lsblk is not available, use psutil
|
|
67
|
+
result = ["=== Block Devices ===\n"]
|
|
68
|
+
partitions = psutil.disk_partitions(all=True)
|
|
69
|
+
|
|
70
|
+
for partition in partitions:
|
|
71
|
+
result.append(f"\nDevice: {partition.device}")
|
|
72
|
+
result.append(f" Mountpoint: {partition.mountpoint}")
|
|
73
|
+
result.append(f" Filesystem: {partition.fstype}")
|
|
74
|
+
result.append(f" Options: {partition.opts}")
|
|
75
|
+
|
|
76
|
+
return "\n".join(result)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
return f"Error listing block devices: {str(e)}"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def list_directories_by_size(
|
|
82
|
+
path: str,
|
|
83
|
+
top_n: int,
|
|
84
|
+
host: Optional[str] = None,
|
|
85
|
+
username: Optional[str] = None
|
|
86
|
+
) -> str:
|
|
87
|
+
"""
|
|
88
|
+
List directories under a specified path sorted by size (largest first).
|
|
89
|
+
|
|
90
|
+
This function uses efficient Linux primitives (du command) to calculate directory
|
|
91
|
+
sizes, making it much faster than Python-based directory traversal.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
path: The directory path to analyze
|
|
95
|
+
top_n: Number of top directories to return (1-1000). Accepts int or float
|
|
96
|
+
(floats are truncated to integers)
|
|
97
|
+
host: Optional remote host to connect to
|
|
98
|
+
username: Optional SSH username (required if host is provided)
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Formatted string with directory sizes, or error message if validation fails
|
|
102
|
+
|
|
103
|
+
Security Features:
|
|
104
|
+
- Path validation and resolution using pathlib
|
|
105
|
+
- Command parameters passed as list (not shell string)
|
|
106
|
+
- Input sanitization for all parameters
|
|
107
|
+
- Graceful error handling for permission issues
|
|
108
|
+
"""
|
|
109
|
+
import os
|
|
110
|
+
from pathlib import Path
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
# Validate and normalize top_n parameter
|
|
114
|
+
top_n, error = validate_positive_int(
|
|
115
|
+
top_n,
|
|
116
|
+
param_name="top_n",
|
|
117
|
+
min_value=1,
|
|
118
|
+
max_value=1000
|
|
119
|
+
)
|
|
120
|
+
if error:
|
|
121
|
+
return error
|
|
122
|
+
|
|
123
|
+
# For local execution, validate path
|
|
124
|
+
if not host:
|
|
125
|
+
try:
|
|
126
|
+
path_obj = Path(path).resolve(strict=True)
|
|
127
|
+
except (OSError, RuntimeError):
|
|
128
|
+
return f"Error: Path does not exist or cannot be resolved: {path}"
|
|
129
|
+
|
|
130
|
+
if not path_obj.is_dir():
|
|
131
|
+
return f"Error: Path is not a directory: {path}"
|
|
132
|
+
|
|
133
|
+
if not os.access(path_obj, os.R_OK):
|
|
134
|
+
return f"Error: Permission denied to read directory: {path}"
|
|
135
|
+
|
|
136
|
+
path_str = str(path_obj)
|
|
137
|
+
else:
|
|
138
|
+
# For remote execution, use the path as-is
|
|
139
|
+
path_str = path
|
|
140
|
+
|
|
141
|
+
# Use du command to get directory sizes efficiently
|
|
142
|
+
returncode, stdout, _ = await execute_command(
|
|
143
|
+
["du", "-b", "--max-depth=1", path_str],
|
|
144
|
+
host=host,
|
|
145
|
+
username=username
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Parse output - du may return non-zero on permission errors but still give valid data
|
|
149
|
+
lines = stdout.strip().split('\n')
|
|
150
|
+
dir_sizes = []
|
|
151
|
+
|
|
152
|
+
for line in lines:
|
|
153
|
+
if not line:
|
|
154
|
+
continue
|
|
155
|
+
parts = line.split('\t', 1)
|
|
156
|
+
if len(parts) == 2:
|
|
157
|
+
try:
|
|
158
|
+
size = int(parts[0])
|
|
159
|
+
dir_path_str = parts[1]
|
|
160
|
+
# Skip the parent directory itself
|
|
161
|
+
dir_name = Path(dir_path_str).name
|
|
162
|
+
if dir_path_str != path_str:
|
|
163
|
+
dir_sizes.append((dir_name, size))
|
|
164
|
+
except (ValueError, IndexError):
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
if not dir_sizes:
|
|
168
|
+
# Only error if we got no output AND a bad return code
|
|
169
|
+
if returncode != 0:
|
|
170
|
+
return f"Error: du command failed and returned no directory data"
|
|
171
|
+
return f"No subdirectories found in: {path}"
|
|
172
|
+
|
|
173
|
+
# Sort by size (descending) and take top N
|
|
174
|
+
dir_sizes.sort(key=lambda x: x[1], reverse=True)
|
|
175
|
+
top_dirs = dir_sizes[:top_n]
|
|
176
|
+
|
|
177
|
+
# Format output
|
|
178
|
+
result = []
|
|
179
|
+
result.append(f"=== Top {len(top_dirs)} Largest Directories ===")
|
|
180
|
+
result.append(f"Path: {path_str}")
|
|
181
|
+
result.append(f"\nTotal subdirectories found: {len(dir_sizes)}\n")
|
|
182
|
+
|
|
183
|
+
for i, (dir_name, size) in enumerate(top_dirs, 1):
|
|
184
|
+
result.append(f"{i}. {dir_name}")
|
|
185
|
+
result.append(f" Size: {format_bytes(size)}")
|
|
186
|
+
|
|
187
|
+
return "\n".join(result)
|
|
188
|
+
|
|
189
|
+
except Exception as e:
|
|
190
|
+
return f"Error analyzing directories: {str(e)}"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
async def list_directories_by_name(
|
|
194
|
+
path: str,
|
|
195
|
+
reverse: bool = False,
|
|
196
|
+
host: Optional[str] = None,
|
|
197
|
+
username: Optional[str] = None
|
|
198
|
+
) -> str:
|
|
199
|
+
"""
|
|
200
|
+
List directories under a specified path sorted by name.
|
|
201
|
+
|
|
202
|
+
This function uses efficient Linux primitives (find and sort) to list directories.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
path: The directory path to analyze
|
|
206
|
+
reverse: If True, sort in reverse alphabetical order (Z-A)
|
|
207
|
+
host: Optional remote host to connect to
|
|
208
|
+
username: Optional SSH username (required if host is provided)
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
Formatted string with directory names, or error message if validation fails
|
|
212
|
+
"""
|
|
213
|
+
import os
|
|
214
|
+
from pathlib import Path
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
# For local execution, validate path
|
|
218
|
+
if not host:
|
|
219
|
+
try:
|
|
220
|
+
path_obj = Path(path).resolve(strict=True)
|
|
221
|
+
except (OSError, RuntimeError):
|
|
222
|
+
return f"Error: Path does not exist or cannot be resolved: {path}"
|
|
223
|
+
|
|
224
|
+
if not path_obj.is_dir():
|
|
225
|
+
return f"Error: Path is not a directory: {path}"
|
|
226
|
+
|
|
227
|
+
if not os.access(path_obj, os.R_OK):
|
|
228
|
+
return f"Error: Permission denied to read directory: {path}"
|
|
229
|
+
|
|
230
|
+
path_str = str(path_obj)
|
|
231
|
+
else:
|
|
232
|
+
# For remote execution, use the path as-is
|
|
233
|
+
path_str = path
|
|
234
|
+
|
|
235
|
+
# Use find to list only immediate subdirectories
|
|
236
|
+
returncode, stdout, _ = await execute_command(
|
|
237
|
+
["find", path_str, "-mindepth", "1", "-maxdepth", "1", "-type", "d", "-printf", "%f\\n"],
|
|
238
|
+
host=host,
|
|
239
|
+
username=username
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if returncode != 0:
|
|
243
|
+
return f"Error running find command: command failed with return code {returncode}"
|
|
244
|
+
|
|
245
|
+
# Parse and sort output
|
|
246
|
+
directories = [line for line in stdout.strip().split('\n') if line]
|
|
247
|
+
|
|
248
|
+
if not directories:
|
|
249
|
+
return f"No subdirectories found in: {path}"
|
|
250
|
+
|
|
251
|
+
# Sort alphabetically
|
|
252
|
+
directories.sort(reverse=reverse)
|
|
253
|
+
|
|
254
|
+
# Format output
|
|
255
|
+
result = []
|
|
256
|
+
sort_order = "Reverse Alphabetical" if reverse else "Alphabetical"
|
|
257
|
+
result.append(f"=== Directories ({sort_order}) ===")
|
|
258
|
+
result.append(f"Path: {path_str}")
|
|
259
|
+
result.append(f"\nTotal subdirectories found: {len(directories)}\n")
|
|
260
|
+
|
|
261
|
+
for i, dir_name in enumerate(directories, 1):
|
|
262
|
+
result.append(f"{i}. {dir_name}")
|
|
263
|
+
|
|
264
|
+
return "\n".join(result)
|
|
265
|
+
|
|
266
|
+
except Exception as e:
|
|
267
|
+
return f"Error listing directories: {str(e)}"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
async def list_directories_by_modified_date(
|
|
271
|
+
path: str,
|
|
272
|
+
newest_first: bool = True,
|
|
273
|
+
host: Optional[str] = None,
|
|
274
|
+
username: Optional[str] = None
|
|
275
|
+
) -> str:
|
|
276
|
+
"""
|
|
277
|
+
List directories under a specified path sorted by modification date.
|
|
278
|
+
|
|
279
|
+
This function uses efficient Linux primitives (find) to list directories with timestamps.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
path: The directory path to analyze
|
|
283
|
+
newest_first: If True, show newest first; if False, show oldest first
|
|
284
|
+
host: Optional remote host to connect to
|
|
285
|
+
username: Optional SSH username (required if host is provided)
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
Formatted string with directory names and dates, or error message if validation fails
|
|
289
|
+
"""
|
|
290
|
+
import os
|
|
291
|
+
from pathlib import Path
|
|
292
|
+
from datetime import datetime
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
# For local execution, validate path
|
|
296
|
+
if not host:
|
|
297
|
+
try:
|
|
298
|
+
path_obj = Path(path).resolve(strict=True)
|
|
299
|
+
except (OSError, RuntimeError):
|
|
300
|
+
return f"Error: Path does not exist or cannot be resolved: {path}"
|
|
301
|
+
|
|
302
|
+
if not path_obj.is_dir():
|
|
303
|
+
return f"Error: Path is not a directory: {path}"
|
|
304
|
+
|
|
305
|
+
if not os.access(path_obj, os.R_OK):
|
|
306
|
+
return f"Error: Permission denied to read directory: {path}"
|
|
307
|
+
|
|
308
|
+
path_str = str(path_obj)
|
|
309
|
+
else:
|
|
310
|
+
# For remote execution, use the path as-is
|
|
311
|
+
path_str = path
|
|
312
|
+
|
|
313
|
+
# Use find with modification time
|
|
314
|
+
returncode, stdout, _ = await execute_command(
|
|
315
|
+
["find", path_str, "-mindepth", "1", "-maxdepth", "1", "-type", "d", "-printf", "%T@\\t%f\\n"],
|
|
316
|
+
host=host,
|
|
317
|
+
username=username
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
if returncode != 0:
|
|
321
|
+
return f"Error running find command: command failed with return code {returncode}"
|
|
322
|
+
|
|
323
|
+
# Parse output
|
|
324
|
+
directories = []
|
|
325
|
+
for line in stdout.strip().split('\n'):
|
|
326
|
+
if line:
|
|
327
|
+
parts = line.split('\t', 1)
|
|
328
|
+
if len(parts) == 2:
|
|
329
|
+
try:
|
|
330
|
+
timestamp = float(parts[0])
|
|
331
|
+
dir_name = parts[1]
|
|
332
|
+
directories.append((timestamp, dir_name))
|
|
333
|
+
except ValueError:
|
|
334
|
+
continue
|
|
335
|
+
|
|
336
|
+
if not directories:
|
|
337
|
+
return f"No subdirectories found in: {path}"
|
|
338
|
+
|
|
339
|
+
# Sort by timestamp
|
|
340
|
+
directories.sort(key=lambda x: x[0], reverse=newest_first)
|
|
341
|
+
|
|
342
|
+
# Format output
|
|
343
|
+
result = []
|
|
344
|
+
sort_order = "Newest First" if newest_first else "Oldest First"
|
|
345
|
+
result.append(f"=== Directories ({sort_order}) ===")
|
|
346
|
+
result.append(f"Path: {path_str}")
|
|
347
|
+
result.append(f"\nTotal subdirectories found: {len(directories)}\n")
|
|
348
|
+
|
|
349
|
+
for i, (timestamp, dir_name) in enumerate(directories, 1):
|
|
350
|
+
dt = datetime.fromtimestamp(timestamp)
|
|
351
|
+
result.append(f"{i}. {dir_name}")
|
|
352
|
+
result.append(f" Modified: {dt.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
353
|
+
|
|
354
|
+
return "\n".join(result)
|
|
355
|
+
|
|
356
|
+
except Exception as e:
|
|
357
|
+
return f"Error listing directories: {str(e)}"
|
|
358
|
+
|