mcp-shell-tools 4.0.0__tar.gz

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.
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ .venv/
5
+ dist/
6
+ build/
7
+ *.egg
8
+ .pytest_cache/
9
+ .claude/
@@ -0,0 +1,176 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-shell-tools
3
+ Version: 4.0.0
4
+ Summary: 26 workstation tools for MCP — filesystem, editor, search, shell, system diagnostics
5
+ Project-URL: Homepage, https://github.com/cuber-it/mcp_tools
6
+ Project-URL: Repository, https://github.com/cuber-it/mcp_tools
7
+ Author-email: Ulrich Berkmueller <ulrich@cuber-it.de>
8
+ License-Expression: MIT
9
+ Keywords: filesystem,mcp,shell,tools,workstation
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: mcp-server-framework>=1.4.0
16
+ Description-Content-Type: text/markdown
17
+
18
+ # mcp-shell-tools
19
+
20
+ 26 workstation tools as a standalone MCP server or proxy plugin — filesystem, editor, search, shell, system diagnostics.
21
+
22
+ Built on [mcp-server-framework](https://pypi.org/project/mcp-server-framework/).
23
+
24
+ > **v4.0 Breaking Change:** Git, HTTP, pip, and systemd tools have been split into dedicated packages
25
+ > (`mcp-git-tools`, `mcp-http-tools`, `mcp-python-tools`, `mcp-systemd-tools`).
26
+ > If you depend on those tools, install the corresponding package or use `mcp-devtools` (planned metapackage).
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ pip install mcp-shell-tools
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ### Standalone
37
+
38
+ ```bash
39
+ mcp-shell-tools # stdio (default)
40
+ mcp-shell-tools --transport http --port 12200 # HTTP with health endpoint
41
+ ```
42
+
43
+ ### Claude Code / Claude Desktop
44
+
45
+ ```json
46
+ {
47
+ "mcpServers": {
48
+ "shell": { "command": "mcp-shell-tools" }
49
+ }
50
+ }
51
+ ```
52
+
53
+ ### As proxy plugin
54
+
55
+ ```yaml
56
+ # proxy.yaml
57
+ autoload:
58
+ - mcp_shell_tools.shell
59
+ ```
60
+
61
+ ## Tools
62
+
63
+ ### Filesystem (11 tools)
64
+
65
+ | Tool | Description |
66
+ |------|-------------|
67
+ | `file_read` | Read a file with optional line range (1-based, with line numbers) |
68
+ | `file_write` | Write content to a file, creates directories if needed |
69
+ | `file_append` | Append content to a file |
70
+ | `file_list` | List directory contents (recursive, hidden files) |
71
+ | `file_delete` | Delete a file or directory (with recursive flag) |
72
+ | `file_move` | Move or rename a file/directory |
73
+ | `file_copy` | Copy a file or directory |
74
+ | `file_info` | File metadata: size, permissions, owner, timestamps |
75
+ | `head` | First N lines of a file |
76
+ | `tail` | Last N lines of a file |
77
+ | `tree` | Directory tree as ASCII art (configurable depth) |
78
+
79
+ ### Editor (3 tools)
80
+
81
+ | Tool | Description |
82
+ |------|-------------|
83
+ | `str_replace` | Replace an exact, unique string in a file |
84
+ | `diff_preview` | Preview a unified diff before applying changes |
85
+ | `find_replace` | Find and replace across multiple files (dry-run by default) |
86
+
87
+ ### Search (2 tools)
88
+
89
+ | Tool | Description |
90
+ |------|-------------|
91
+ | `grep` | Search file contents with regex (recursive, case-insensitive, glob filter) |
92
+ | `glob_search` | Find files by glob pattern (e.g. `**/*.py`) |
93
+
94
+ ### Shell (6 tools)
95
+
96
+ | Tool | Description |
97
+ |------|-------------|
98
+ | `exec` | Execute a shell command (bash) with configurable timeout |
99
+ | `cd` | Change working directory (persists across calls) |
100
+ | `cwd` | Show current working directory |
101
+ | `which` | Find the full path of a command |
102
+ | `env` | Show environment variables (all or specific) |
103
+ | `set_env` | Set or unset environment variables for shell_exec |
104
+
105
+ ### System (4 tools)
106
+
107
+ | Tool | Description |
108
+ |------|-------------|
109
+ | `ps` | List running processes (filterable by name) |
110
+ | `sysinfo` | System overview: OS, CPU, memory, disk, uptime, load |
111
+ | `port_check` | Check what's listening on a port, or list all open ports |
112
+ | `disk_usage` | Disk usage of a directory and subdirectories |
113
+
114
+ ## Security
115
+
116
+ The shell tools include a configurable security sandbox:
117
+
118
+ ```yaml
119
+ # Restrict filesystem access to specific paths
120
+ allowed_paths:
121
+ - /home/user/projects
122
+ - /tmp
123
+
124
+ # Block dangerous commands
125
+ blocked_commands:
126
+ - "sudo"
127
+ - "rm -rf /"
128
+ - "mkfs"
129
+ ```
130
+
131
+ - **Path restriction**: When `allowed_paths` is set, all filesystem operations are confined to those directories. Symlinks pointing outside are rejected.
132
+ - **Command blocking**: `shell_exec` checks commands against the blocklist before execution. Partial matches work — blocking `sudo` also blocks `sudo rm`.
133
+ - **No restriction by default**: Without config, all paths and commands are allowed. This is intentional for development workstations. Lock it down for shared or production environments.
134
+
135
+ ## Configuration
136
+
137
+ ```yaml
138
+ server_name: "Shell Tools"
139
+ transport: stdio # stdio | http
140
+ working_dir: /home/user # initial cwd (optional)
141
+ timeout: 120 # default shell_exec timeout in seconds
142
+
143
+ # Security (optional)
144
+ allowed_paths: ["/home/user/projects"]
145
+ blocked_commands: ["sudo", "rm -rf /"]
146
+
147
+ # HTTP mode
148
+ port: 12200
149
+ health_port: 12201
150
+ ```
151
+
152
+ Environment variables override YAML config with `MCP_` prefix:
153
+
154
+ ```bash
155
+ export MCP_TRANSPORT=http
156
+ export MCP_PORT=12200
157
+ ```
158
+
159
+ ## What moved where (v3 → v4)
160
+
161
+ | v3 tool | v4 package | PyPI |
162
+ |---------|------------|------|
163
+ | `git`, `git_status`, `git_log`, `git_diff` | mcp-git-tools | `pip install mcp-git-tools` |
164
+ | `http_request`, `json_query` | mcp-http-tools | `pip install mcp-http-tools` |
165
+ | `pip_list`, `pip_install` | mcp-python-tools | `pip install mcp-python-tools` |
166
+ | `systemctl` | mcp-systemd-tools | `pip install mcp-systemd-tools` |
167
+
168
+ This split follows the principle: when package creation costs nothing, optimal package size gets smaller. Each package does one thing well, and you only install what you need.
169
+
170
+ ## Part of mcp_tools
171
+
172
+ This package is part of the [mcp_tools](https://github.com/cuber-it/mcp_tools) ecosystem — modular MCP tool packages that work standalone and as plugins.
173
+
174
+ ## License
175
+
176
+ MIT
@@ -0,0 +1,159 @@
1
+ # mcp-shell-tools
2
+
3
+ 26 workstation tools as a standalone MCP server or proxy plugin — filesystem, editor, search, shell, system diagnostics.
4
+
5
+ Built on [mcp-server-framework](https://pypi.org/project/mcp-server-framework/).
6
+
7
+ > **v4.0 Breaking Change:** Git, HTTP, pip, and systemd tools have been split into dedicated packages
8
+ > (`mcp-git-tools`, `mcp-http-tools`, `mcp-python-tools`, `mcp-systemd-tools`).
9
+ > If you depend on those tools, install the corresponding package or use `mcp-devtools` (planned metapackage).
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ pip install mcp-shell-tools
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ### Standalone
20
+
21
+ ```bash
22
+ mcp-shell-tools # stdio (default)
23
+ mcp-shell-tools --transport http --port 12200 # HTTP with health endpoint
24
+ ```
25
+
26
+ ### Claude Code / Claude Desktop
27
+
28
+ ```json
29
+ {
30
+ "mcpServers": {
31
+ "shell": { "command": "mcp-shell-tools" }
32
+ }
33
+ }
34
+ ```
35
+
36
+ ### As proxy plugin
37
+
38
+ ```yaml
39
+ # proxy.yaml
40
+ autoload:
41
+ - mcp_shell_tools.shell
42
+ ```
43
+
44
+ ## Tools
45
+
46
+ ### Filesystem (11 tools)
47
+
48
+ | Tool | Description |
49
+ |------|-------------|
50
+ | `file_read` | Read a file with optional line range (1-based, with line numbers) |
51
+ | `file_write` | Write content to a file, creates directories if needed |
52
+ | `file_append` | Append content to a file |
53
+ | `file_list` | List directory contents (recursive, hidden files) |
54
+ | `file_delete` | Delete a file or directory (with recursive flag) |
55
+ | `file_move` | Move or rename a file/directory |
56
+ | `file_copy` | Copy a file or directory |
57
+ | `file_info` | File metadata: size, permissions, owner, timestamps |
58
+ | `head` | First N lines of a file |
59
+ | `tail` | Last N lines of a file |
60
+ | `tree` | Directory tree as ASCII art (configurable depth) |
61
+
62
+ ### Editor (3 tools)
63
+
64
+ | Tool | Description |
65
+ |------|-------------|
66
+ | `str_replace` | Replace an exact, unique string in a file |
67
+ | `diff_preview` | Preview a unified diff before applying changes |
68
+ | `find_replace` | Find and replace across multiple files (dry-run by default) |
69
+
70
+ ### Search (2 tools)
71
+
72
+ | Tool | Description |
73
+ |------|-------------|
74
+ | `grep` | Search file contents with regex (recursive, case-insensitive, glob filter) |
75
+ | `glob_search` | Find files by glob pattern (e.g. `**/*.py`) |
76
+
77
+ ### Shell (6 tools)
78
+
79
+ | Tool | Description |
80
+ |------|-------------|
81
+ | `exec` | Execute a shell command (bash) with configurable timeout |
82
+ | `cd` | Change working directory (persists across calls) |
83
+ | `cwd` | Show current working directory |
84
+ | `which` | Find the full path of a command |
85
+ | `env` | Show environment variables (all or specific) |
86
+ | `set_env` | Set or unset environment variables for shell_exec |
87
+
88
+ ### System (4 tools)
89
+
90
+ | Tool | Description |
91
+ |------|-------------|
92
+ | `ps` | List running processes (filterable by name) |
93
+ | `sysinfo` | System overview: OS, CPU, memory, disk, uptime, load |
94
+ | `port_check` | Check what's listening on a port, or list all open ports |
95
+ | `disk_usage` | Disk usage of a directory and subdirectories |
96
+
97
+ ## Security
98
+
99
+ The shell tools include a configurable security sandbox:
100
+
101
+ ```yaml
102
+ # Restrict filesystem access to specific paths
103
+ allowed_paths:
104
+ - /home/user/projects
105
+ - /tmp
106
+
107
+ # Block dangerous commands
108
+ blocked_commands:
109
+ - "sudo"
110
+ - "rm -rf /"
111
+ - "mkfs"
112
+ ```
113
+
114
+ - **Path restriction**: When `allowed_paths` is set, all filesystem operations are confined to those directories. Symlinks pointing outside are rejected.
115
+ - **Command blocking**: `shell_exec` checks commands against the blocklist before execution. Partial matches work — blocking `sudo` also blocks `sudo rm`.
116
+ - **No restriction by default**: Without config, all paths and commands are allowed. This is intentional for development workstations. Lock it down for shared or production environments.
117
+
118
+ ## Configuration
119
+
120
+ ```yaml
121
+ server_name: "Shell Tools"
122
+ transport: stdio # stdio | http
123
+ working_dir: /home/user # initial cwd (optional)
124
+ timeout: 120 # default shell_exec timeout in seconds
125
+
126
+ # Security (optional)
127
+ allowed_paths: ["/home/user/projects"]
128
+ blocked_commands: ["sudo", "rm -rf /"]
129
+
130
+ # HTTP mode
131
+ port: 12200
132
+ health_port: 12201
133
+ ```
134
+
135
+ Environment variables override YAML config with `MCP_` prefix:
136
+
137
+ ```bash
138
+ export MCP_TRANSPORT=http
139
+ export MCP_PORT=12200
140
+ ```
141
+
142
+ ## What moved where (v3 → v4)
143
+
144
+ | v3 tool | v4 package | PyPI |
145
+ |---------|------------|------|
146
+ | `git`, `git_status`, `git_log`, `git_diff` | mcp-git-tools | `pip install mcp-git-tools` |
147
+ | `http_request`, `json_query` | mcp-http-tools | `pip install mcp-http-tools` |
148
+ | `pip_list`, `pip_install` | mcp-python-tools | `pip install mcp-python-tools` |
149
+ | `systemctl` | mcp-systemd-tools | `pip install mcp-systemd-tools` |
150
+
151
+ This split follows the principle: when package creation costs nothing, optimal package size gets smaller. Each package does one thing well, and you only install what you need.
152
+
153
+ ## Part of mcp_tools
154
+
155
+ This package is part of the [mcp_tools](https://github.com/cuber-it/mcp_tools) ecosystem — modular MCP tool packages that work standalone and as plugins.
156
+
157
+ ## License
158
+
159
+ MIT
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "mcp-shell-tools"
3
+ version = "4.0.0"
4
+ description = "26 workstation tools for MCP — filesystem, editor, search, shell, system diagnostics"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.10"
8
+ authors = [
9
+ { name = "Ulrich Berkmueller", email = "ulrich@cuber-it.de" },
10
+ ]
11
+ keywords = ["mcp", "shell", "tools", "filesystem", "workstation"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ ]
18
+ dependencies = [
19
+ "mcp-server-framework>=1.4.0",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/cuber-it/mcp_tools"
24
+ Repository = "https://github.com/cuber-it/mcp_tools"
25
+
26
+ [project.scripts]
27
+ mcp-shell-tools = "mcp_shell_tools:main"
28
+
29
+ [build-system]
30
+ requires = ["hatchling"]
31
+ build-backend = "hatchling.build"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/mcp_shell_tools"]
@@ -0,0 +1,16 @@
1
+ """mcp-shell-tools — 33 workstation tools as a standalone MCP server.
2
+
3
+ Built on mcp-server-framework. Can also be used as a plugin via register().
4
+ """
5
+
6
+ __version__ = "3.0.0"
7
+
8
+
9
+ def main():
10
+ from mcp_server_framework import load_config, create_server, run_server
11
+ from mcp_shell_tools.shell import register
12
+
13
+ config = load_config()
14
+ mcp = create_server(config)
15
+ register(mcp, config)
16
+ run_server(mcp, config)
@@ -0,0 +1,203 @@
1
+ """Shell tools — MCP plugin for workstation access.
2
+
3
+ Provides filesystem, editor, search, shell execution, and system diagnostics.
4
+ Git, HTTP, pip, and systemd have been split into separate packages (v4.0).
5
+
6
+ Config keys:
7
+ working_dir: Initial working directory (optional, defaults to cwd)
8
+ timeout: Default shell timeout in seconds (optional, default 120)
9
+ allowed_paths: Restrict filesystem access (optional)
10
+ blocked_commands: Block dangerous commands (optional)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import functools
17
+
18
+ from . import tools
19
+
20
+
21
+ def register(mcp, config: dict) -> None:
22
+ """Register shell tools as MCP tools."""
23
+ if config.get("working_dir"):
24
+ from pathlib import Path
25
+ tools.set_working_dir(Path(config["working_dir"]))
26
+
27
+ tools.set_security_boundaries(
28
+ allowed_paths=config.get("allowed_paths"),
29
+ blocked_commands=config.get("blocked_commands"),
30
+ )
31
+
32
+ default_timeout = config.get("timeout", 120)
33
+
34
+ # Auto-record all tool calls in history
35
+ _original_tool = mcp.tool
36
+
37
+ def _recording_tool(**kwargs):
38
+ def decorator(fn):
39
+ name = fn.__name__.replace("shell_", "")
40
+
41
+ @functools.wraps(fn)
42
+ def wrapper(*args, **kw):
43
+ result = fn(*args, **kw)
44
+ args_str = ", ".join(
45
+ [repr(a) for a in args] + [f"{k}={v!r}" for k, v in kw.items()]
46
+ )
47
+ tools._record(name, args_str, str(result))
48
+ return result
49
+
50
+ return _original_tool(**kwargs)(wrapper)
51
+ return decorator
52
+
53
+ tool = _recording_tool
54
+
55
+ # --- Filesystem ---
56
+
57
+ @tool()
58
+ def shell_file_read(path: str, start_line: int = 0, end_line: int = 0) -> str:
59
+ """Read a file with optional line range (1-based). Returns content with line numbers."""
60
+ return tools.file_read(path, start_line or None, end_line or None)
61
+
62
+ @tool()
63
+ def shell_file_write(path: str, content: str) -> str:
64
+ """Write content to a file. Creates directories if needed."""
65
+ return tools.file_write(path, content)
66
+
67
+ @tool()
68
+ def shell_file_append(path: str, content: str) -> str:
69
+ """Append content to a file. Creates file and directories if needed."""
70
+ return tools.file_append(path, content)
71
+
72
+ @tool()
73
+ def shell_file_list(path: str = ".", recursive: bool = False, show_hidden: bool = False) -> str:
74
+ """List files and directories."""
75
+ return tools.file_list(path, recursive, show_hidden)
76
+
77
+ @tool()
78
+ def shell_file_delete(path: str, recursive: bool = False) -> str:
79
+ """Delete a file or directory. Use recursive=True for directories."""
80
+ return tools.file_delete(path, recursive)
81
+
82
+ @tool()
83
+ def shell_file_move(source: str, destination: str) -> str:
84
+ """Move or rename a file/directory."""
85
+ return tools.file_move(source, destination)
86
+
87
+ @tool()
88
+ def shell_file_copy(source: str, destination: str) -> str:
89
+ """Copy a file or directory."""
90
+ return tools.file_copy(source, destination)
91
+
92
+ @tool()
93
+ def shell_file_info(path: str) -> str:
94
+ """Show file metadata: size, permissions, owner, timestamps."""
95
+ return tools.file_info(path)
96
+
97
+ @tool()
98
+ def shell_head(path: str, lines: int = 20) -> str:
99
+ """Show first N lines of a file (default 20)."""
100
+ return tools.head(path, lines)
101
+
102
+ @tool()
103
+ def shell_tail(path: str, lines: int = 20) -> str:
104
+ """Show last N lines of a file (default 20)."""
105
+ return tools.tail(path, lines)
106
+
107
+ @tool()
108
+ def shell_tree(path: str = ".", max_depth: int = 3, show_hidden: bool = False) -> str:
109
+ """Show directory tree as ASCII art."""
110
+ return tools.tree(path, max_depth, show_hidden)
111
+
112
+ # --- Editor ---
113
+
114
+ @tool()
115
+ def shell_str_replace(path: str, old_string: str, new_string: str) -> str:
116
+ """Replace an exact, unique string in a file."""
117
+ return tools.str_replace(path, old_string, new_string)
118
+
119
+ @tool()
120
+ def shell_diff_preview(path: str, old_string: str, new_string: str = "", context_lines: int = 3) -> str:
121
+ """Show unified diff preview before applying str_replace."""
122
+ return tools.diff_preview(path, old_string, new_string, context_lines)
123
+
124
+ @tool()
125
+ def shell_find_replace(pattern: str, replacement: str, path: str = ".", file_pattern: str = "*", dry_run: bool = True) -> str:
126
+ """Find and replace text across files. Default: dry_run preview. Set dry_run=False to apply."""
127
+ return tools.find_replace(pattern, replacement, path, file_pattern, dry_run)
128
+
129
+ # --- Search ---
130
+
131
+ @tool()
132
+ def shell_grep(
133
+ pattern: str, path: str = ".", recursive: bool = True,
134
+ ignore_case: bool = False, file_pattern: str = "*", max_results: int = 50,
135
+ ) -> str:
136
+ """Search for a pattern (text or regex) in files."""
137
+ return tools.grep(pattern, path, recursive, ignore_case, file_pattern, max_results)
138
+
139
+ @tool()
140
+ def shell_glob(pattern: str, path: str = ".") -> str:
141
+ """Search files by glob pattern (e.g. '**/*.py')."""
142
+ return tools.glob_search(pattern, path)
143
+
144
+ # --- Shell ---
145
+
146
+ @tool()
147
+ def shell_exec(command: str, timeout: int = default_timeout, working_dir: str = "") -> str:
148
+ """Execute a shell command (bash). Returns stdout, stderr, exit code."""
149
+ loop = asyncio.get_event_loop()
150
+ if loop.is_running():
151
+ import concurrent.futures
152
+ with concurrent.futures.ThreadPoolExecutor() as pool:
153
+ future = pool.submit(asyncio.run, tools.shell_exec(command, timeout, working_dir or None))
154
+ return future.result()
155
+ return asyncio.run(tools.shell_exec(command, timeout, working_dir or None))
156
+
157
+ @tool()
158
+ def shell_cd(path: str) -> str:
159
+ """Change working directory."""
160
+ return tools.cd(path)
161
+
162
+ @tool()
163
+ def shell_cwd() -> str:
164
+ """Show current working directory."""
165
+ return tools.cwd()
166
+
167
+ @tool()
168
+ def shell_which(command: str) -> str:
169
+ """Find the full path of a command (like 'which' in shell)."""
170
+ return tools.which(command)
171
+
172
+ @tool()
173
+ def shell_env(name: str = "") -> str:
174
+ """Show environment variables. Without name: show custom vars only."""
175
+ return tools.env(name)
176
+
177
+ @tool()
178
+ def shell_set_env(name: str, value: str = "") -> str:
179
+ """Set or delete an environment variable for shell_exec calls."""
180
+ return tools.set_env(name, value)
181
+
182
+ # --- System Diagnostics ---
183
+
184
+ @tool()
185
+ def shell_ps(filter: str = "") -> str:
186
+ """Show running processes. Optional filter by name or PID."""
187
+ return tools.ps(filter)
188
+
189
+ @tool()
190
+ def shell_sysinfo() -> str:
191
+ """System overview: OS, CPU, memory, disk, uptime, load."""
192
+ return tools.sysinfo()
193
+
194
+ @tool()
195
+ def shell_port_check(port: int = 0, host: str = "127.0.0.1") -> str:
196
+ """Check what's listening on a port, or list all listening ports."""
197
+ return tools.port_check(port, host)
198
+
199
+ @tool()
200
+ def shell_disk_usage(path: str = ".") -> str:
201
+ """Show disk usage of directory and its subdirectories."""
202
+ return tools.disk_usage(path)
203
+
@@ -0,0 +1,53 @@
1
+ """Tool call history tracking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+
9
+ # ── History ──────────────────────────────────────────────────────────
10
+
11
+ @dataclass
12
+ class HistoryEntry:
13
+ seq: int
14
+ timestamp: str
15
+ tool: str
16
+ args: str
17
+ result: str
18
+
19
+
20
+ _history: list[HistoryEntry] = []
21
+ _session_id: str = uuid.uuid4().hex[:8]
22
+ _seq: int = 0
23
+
24
+
25
+ def record(tool: str, args: str, result: str) -> None:
26
+ global _seq
27
+ _seq += 1
28
+ _history.append(HistoryEntry(
29
+ seq=_seq,
30
+ timestamp=datetime.now().strftime("%H:%M:%S"),
31
+ tool=tool,
32
+ args=args[:500],
33
+ result=result[:5000],
34
+ ))
35
+ if len(_history) > 1000:
36
+ _history.pop(0)
37
+
38
+
39
+ def history(count: int = 20, filter: str = "", full: bool = False) -> str:
40
+ """Show tool call history."""
41
+ if not _history:
42
+ return "No history yet."
43
+ entries = _history
44
+ if filter:
45
+ f = filter.lower()
46
+ entries = [e for e in entries if f in e.tool or f in e.args.lower()]
47
+ selected = entries[-count:]
48
+ lines = [f"Session {_session_id} ({_seq} calls)\n"]
49
+ for e in selected:
50
+ lines.append(f"#{e.seq} [{e.timestamp}] {e.tool}({e.args})")
51
+ preview = e.result if full else e.result[:200].replace("\n", " ")
52
+ lines.append(f" -> {preview}")
53
+ return "\n".join(lines)
@@ -0,0 +1,96 @@
1
+ """Shared state, security checks, and helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ # ── Mutable state ────────────────────────────────────────────────────
11
+
12
+ _working_dir: Path = Path.cwd()
13
+ _allowed_paths: list[Path] = []
14
+ _blocked_commands: list[str] = []
15
+ _custom_env: dict[str, str] = {}
16
+
17
+
18
+ def set_working_dir(path: Path) -> None:
19
+ global _working_dir
20
+ _working_dir = path.resolve()
21
+
22
+
23
+ def get_working_dir() -> Path:
24
+ return _working_dir
25
+
26
+
27
+ def get_custom_env() -> dict[str, str]:
28
+ return _custom_env
29
+
30
+
31
+ def set_security_boundaries(
32
+ allowed_paths: list[str] | None = None,
33
+ blocked_commands: list[str] | None = None,
34
+ ) -> None:
35
+ """Set path and command restrictions."""
36
+ global _allowed_paths, _blocked_commands
37
+ _allowed_paths = [Path(p).resolve() for p in (allowed_paths or [])]
38
+ _blocked_commands = [c.strip() for c in (blocked_commands or [])]
39
+
40
+
41
+ # ── Checks ───────────────────────────────────────────────────────────
42
+
43
+ def resolve_path(path: str) -> Path:
44
+ p = Path(path)
45
+ return p.resolve() if p.is_absolute() else (_working_dir / p).resolve()
46
+
47
+
48
+ def check_path(resolved: Path) -> str | None:
49
+ if not _allowed_paths:
50
+ return None
51
+ for allowed in _allowed_paths:
52
+ try:
53
+ resolved.relative_to(allowed)
54
+ return None
55
+ except ValueError:
56
+ continue
57
+ return f"Error: path outside allowed directories: {resolved}"
58
+
59
+
60
+ def check_command(command: str) -> str | None:
61
+ if not _blocked_commands:
62
+ return None
63
+ cmd = command.strip()
64
+ for blocked in _blocked_commands:
65
+ if cmd.startswith(blocked) or f"| {blocked}" in cmd or f"; {blocked}" in cmd:
66
+ return f"Error: '{blocked}' is blocked"
67
+ return None
68
+
69
+
70
+ # ── Helpers ──────────────────────────────────────────────────────────
71
+
72
+ def read_text(resolved: Path) -> str | None:
73
+ """Read file as UTF-8, return None on binary."""
74
+ try:
75
+ return resolved.read_text(encoding="utf-8")
76
+ except UnicodeDecodeError:
77
+ return None
78
+
79
+
80
+ def run(cmd: list[str], timeout: int = 10) -> str:
81
+ """Run a subprocess, return stdout+stderr."""
82
+ out = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
83
+ result = out.stdout.strip()
84
+ if out.stderr.strip():
85
+ result += ("\n[STDERR] " if out.returncode else "\n") + out.stderr.strip()
86
+ if out.returncode and not result:
87
+ result = f"[Exit {out.returncode}]"
88
+ return result or "(no output)"
89
+
90
+
91
+ def format_size(size: int) -> str:
92
+ if size < 1024:
93
+ return f"{size} bytes"
94
+ if size < 1048576:
95
+ return f"{size / 1024:.1f} KB"
96
+ return f"{size / 1048576:.1f} MB"
@@ -0,0 +1,86 @@
1
+ """Text editing: str_replace, diff preview, find & replace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ from pathlib import Path
7
+
8
+ from ._state import check_path, read_text, resolve_path
9
+
10
+
11
+ def str_replace(path: str, old_string: str, new_string: str) -> str:
12
+ """Replace exact, unique string in a file."""
13
+ resolved = resolve_path(path)
14
+ if err := check_path(resolved):
15
+ return err
16
+ if not resolved.is_file():
17
+ return f"Error: not a file: {resolved}"
18
+ content = resolved.read_text(encoding="utf-8")
19
+ count = content.count(old_string)
20
+ if count == 0:
21
+ return f"Error: string not found in {resolved}"
22
+ if count > 1:
23
+ return f"Error: string appears {count} times, must be unique"
24
+ resolved.write_text(content.replace(old_string, new_string, 1), encoding="utf-8")
25
+ return f"Replaced in {resolved}"
26
+
27
+
28
+ def diff_preview(path: str, old_string: str, new_string: str = "", context_lines: int = 3) -> str:
29
+ """Unified diff preview before applying str_replace."""
30
+ resolved = resolve_path(path)
31
+ if err := check_path(resolved):
32
+ return err
33
+ if not resolved.is_file():
34
+ return f"Error: not a file: {resolved}"
35
+ content = resolved.read_text(encoding="utf-8")
36
+ count = content.count(old_string)
37
+ if count == 0:
38
+ return f"Error: string not found in {resolved}"
39
+ if count > 1:
40
+ return f"Warning: appears {count} times, str_replace would fail"
41
+ new_content = content.replace(old_string, new_string, 1)
42
+ diff = difflib.unified_diff(
43
+ content.splitlines(keepends=True),
44
+ new_content.splitlines(keepends=True),
45
+ fromfile=f"a/{resolved.name}",
46
+ tofile=f"b/{resolved.name}",
47
+ n=context_lines,
48
+ )
49
+ return "".join(diff) or "No changes"
50
+
51
+
52
+ def find_replace(
53
+ pattern: str, replacement: str, path: str = ".",
54
+ file_pattern: str = "*", dry_run: bool = True,
55
+ ) -> str:
56
+ """Find and replace across files. dry_run=True for preview."""
57
+ resolved = resolve_path(path)
58
+ if not resolved.exists():
59
+ return f"Error: not found: {resolved}"
60
+ if resolved.is_file():
61
+ files = [resolved]
62
+ else:
63
+ files = sorted(
64
+ f for f in resolved.rglob(file_pattern)
65
+ if f.is_file() and not any(p.startswith(".") for p in f.parts)
66
+ )
67
+ results = []
68
+ total = 0
69
+ for f in files:
70
+ if check_path(f):
71
+ continue
72
+ content = read_text(f)
73
+ if not content:
74
+ continue
75
+ count = content.count(pattern)
76
+ if count == 0:
77
+ continue
78
+ rel = f.relative_to(resolved) if resolved.is_dir() else f.name
79
+ if not dry_run:
80
+ f.write_text(content.replace(pattern, replacement), encoding="utf-8")
81
+ results.append(f" {rel}: {count}x")
82
+ total += count
83
+ if not results:
84
+ return f"No matches for '{pattern}'"
85
+ mode = "PREVIEW" if dry_run else "APPLIED"
86
+ return f"[{mode}] {total} in {len(results)} file(s)\n" + "\n".join(results)
@@ -0,0 +1,229 @@
1
+ """Filesystem operations: read, write, copy, move, delete, list, tree."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from ._state import check_path, format_size, read_text, resolve_path
10
+
11
+
12
+ def file_read(path: str, start_line: int | None = None, end_line: int | None = None) -> str:
13
+ """Read file with optional line range (1-based). Returns numbered lines."""
14
+ resolved = resolve_path(path)
15
+ if err := check_path(resolved):
16
+ return err
17
+ if not resolved.exists():
18
+ return f"Error: not found: {resolved}"
19
+ if not resolved.is_file():
20
+ return f"Error: not a file: {resolved}"
21
+ content = read_text(resolved)
22
+ if content is None:
23
+ return f"Binary file: {resolved} ({resolved.stat().st_size:,} bytes)"
24
+ lines = content.splitlines()
25
+ total = len(lines)
26
+ start = (start_line or 1) - 1
27
+ end = end_line or min(total, 500)
28
+ numbered = [f"{i + start + 1:>5} | {ln}" for i, ln in enumerate(lines[start:end])]
29
+ result = "\n".join(numbered)
30
+ if not start_line and not end_line and total > 500:
31
+ result += f"\n[{total} lines total, showing first 500]"
32
+ return result
33
+
34
+
35
+ def file_write(path: str, content: str) -> str:
36
+ """Write content to file. Creates directories if needed."""
37
+ resolved = resolve_path(path)
38
+ if err := check_path(resolved):
39
+ return err
40
+ resolved.parent.mkdir(parents=True, exist_ok=True)
41
+ resolved.write_text(content, encoding="utf-8")
42
+ return f"Written: {resolved} ({len(content.splitlines())} lines)"
43
+
44
+
45
+ def file_append(path: str, content: str) -> str:
46
+ """Append content to file. Creates file if needed."""
47
+ resolved = resolve_path(path)
48
+ if err := check_path(resolved):
49
+ return err
50
+ resolved.parent.mkdir(parents=True, exist_ok=True)
51
+ with open(resolved, "a", encoding="utf-8") as f:
52
+ f.write(content)
53
+ return f"Appended {len(content.splitlines())} lines to: {resolved}"
54
+
55
+
56
+ def file_list(path: str = ".", recursive: bool = False, show_hidden: bool = False) -> str:
57
+ """List files and directories."""
58
+ resolved = resolve_path(path)
59
+ if err := check_path(resolved):
60
+ return err
61
+ if not resolved.is_dir():
62
+ return f"Error: not a directory: {resolved}"
63
+ lines = [str(resolved)]
64
+ items = sorted(resolved.rglob("*") if recursive else resolved.iterdir())
65
+ for item in items[:200]:
66
+ if not show_hidden and item.name.startswith("."):
67
+ continue
68
+ rel = item.relative_to(resolved)
69
+ suffix = "/" if item.is_dir() else f" ({item.stat().st_size:,} bytes)"
70
+ lines.append(f" {rel}{suffix}")
71
+ if len(items) > 200:
72
+ lines.append(f"[... {len(items) - 200} more]")
73
+ return "\n".join(lines)
74
+
75
+
76
+ def file_delete(path: str, recursive: bool = False) -> str:
77
+ """Delete file or directory."""
78
+ resolved = resolve_path(path)
79
+ if err := check_path(resolved):
80
+ return err
81
+ if not resolved.exists():
82
+ return f"Error: not found: {resolved}"
83
+ if resolved.is_dir():
84
+ if not recursive:
85
+ return "Error: is a directory, use recursive=True"
86
+ shutil.rmtree(resolved)
87
+ else:
88
+ resolved.unlink()
89
+ return f"Deleted: {resolved}"
90
+
91
+
92
+ def file_move(source: str, destination: str) -> str:
93
+ """Move or rename file/directory."""
94
+ src, dst = resolve_path(source), resolve_path(destination)
95
+ if err := check_path(src):
96
+ return err
97
+ if err := check_path(dst):
98
+ return err
99
+ if not src.exists():
100
+ return f"Error: not found: {src}"
101
+ dst.parent.mkdir(parents=True, exist_ok=True)
102
+ shutil.move(str(src), str(dst))
103
+ return f"Moved: {src} -> {dst}"
104
+
105
+
106
+ def file_copy(source: str, destination: str) -> str:
107
+ """Copy file or directory."""
108
+ src, dst = resolve_path(source), resolve_path(destination)
109
+ if err := check_path(src):
110
+ return err
111
+ if err := check_path(dst):
112
+ return err
113
+ if not src.exists():
114
+ return f"Error: not found: {src}"
115
+ dst.parent.mkdir(parents=True, exist_ok=True)
116
+ if src.is_dir():
117
+ shutil.copytree(str(src), str(dst))
118
+ else:
119
+ shutil.copy2(str(src), str(dst))
120
+ return f"Copied: {src} -> {dst}"
121
+
122
+
123
+ def file_info(path: str) -> str:
124
+ """File metadata: size, permissions, owner, timestamps."""
125
+ import grp
126
+ import pwd
127
+ import stat as stat_mod
128
+
129
+ resolved = resolve_path(path)
130
+ if err := check_path(resolved):
131
+ return err
132
+ if not resolved.exists():
133
+ return f"Error: not found: {resolved}"
134
+ st = resolved.stat()
135
+ try:
136
+ owner = pwd.getpwuid(st.st_uid).pw_name
137
+ except KeyError:
138
+ owner = str(st.st_uid)
139
+ try:
140
+ group = grp.getgrgid(st.st_gid).gr_name
141
+ except KeyError:
142
+ group = str(st.st_gid)
143
+ ftype = "directory" if resolved.is_dir() else "symlink" if resolved.is_symlink() else "file"
144
+ lines = [
145
+ f"Path: {resolved}",
146
+ f"Type: {ftype}",
147
+ f"Size: {format_size(st.st_size)}",
148
+ f"Permissions: {stat_mod.filemode(st.st_mode)}",
149
+ f"Owner: {owner}:{group}",
150
+ f"Modified: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(st.st_mtime))}",
151
+ ]
152
+ if resolved.is_symlink():
153
+ lines.append(f"Target: {resolved.resolve()}")
154
+ return "\n".join(lines)
155
+
156
+
157
+ def head(path: str, lines: int = 20) -> str:
158
+ """First N lines of a file."""
159
+ resolved = resolve_path(path)
160
+ if err := check_path(resolved):
161
+ return err
162
+ if not resolved.is_file():
163
+ return f"Error: not a file: {resolved}"
164
+ content = read_text(resolved)
165
+ if content is None:
166
+ return f"Binary file: {resolved}"
167
+ all_lines = content.splitlines()
168
+ numbered = [f"{i + 1:>5} | {ln}" for i, ln in enumerate(all_lines[:lines])]
169
+ result = "\n".join(numbered)
170
+ if len(all_lines) > lines:
171
+ result += f"\n[{len(all_lines)} lines total]"
172
+ return result
173
+
174
+
175
+ def tail(path: str, lines: int = 20) -> str:
176
+ """Last N lines of a file."""
177
+ resolved = resolve_path(path)
178
+ if err := check_path(resolved):
179
+ return err
180
+ if not resolved.is_file():
181
+ return f"Error: not a file: {resolved}"
182
+ content = read_text(resolved)
183
+ if content is None:
184
+ return f"Binary file: {resolved}"
185
+ all_lines = content.splitlines()
186
+ total = len(all_lines)
187
+ start = max(0, total - lines)
188
+ numbered = [f"{start + i + 1:>5} | {ln}" for i, ln in enumerate(all_lines[start:])]
189
+ result = "\n".join(numbered)
190
+ if total > lines:
191
+ result = f"[{total} lines total]\n" + result
192
+ return result
193
+
194
+
195
+ def tree(path: str = ".", max_depth: int = 3, show_hidden: bool = False) -> str:
196
+ """Directory tree as ASCII art."""
197
+ resolved = resolve_path(path)
198
+ if err := check_path(resolved):
199
+ return err
200
+ if not resolved.is_dir():
201
+ return f"Error: not a directory: {resolved}"
202
+
203
+ lines = [str(resolved)]
204
+
205
+ def walk(dir_path: Path, prefix: str, depth: int) -> None:
206
+ if depth > max_depth:
207
+ return
208
+ try:
209
+ entries = sorted(dir_path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
210
+ except PermissionError:
211
+ lines.append(f"{prefix}[permission denied]")
212
+ return
213
+ if not show_hidden:
214
+ entries = [e for e in entries if not e.name.startswith(".")]
215
+ for i, entry in enumerate(entries):
216
+ last = i == len(entries) - 1
217
+ connector = "└── " if last else "├── "
218
+ if entry.is_dir():
219
+ lines.append(f"{prefix}{connector}{entry.name}/")
220
+ walk(entry, prefix + (" " if last else "│ "), depth + 1)
221
+ else:
222
+ sz = entry.stat().st_size
223
+ s = f"{sz}B" if sz < 1024 else f"{sz // 1024}K" if sz < 1048576 else f"{sz // 1048576}M"
224
+ lines.append(f"{prefix}{connector}{entry.name} ({s})")
225
+
226
+ walk(resolved, "", 1)
227
+ if len(lines) > 200:
228
+ lines = lines[:200] + ["[... truncated]"]
229
+ return "\n".join(lines)
@@ -0,0 +1,55 @@
1
+ """Search: grep and glob."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from ._state import check_path, read_text, resolve_path
8
+
9
+
10
+ def grep(
11
+ pattern: str, path: str = ".", recursive: bool = True,
12
+ ignore_case: bool = False, file_pattern: str = "*", max_results: int = 50,
13
+ ) -> str:
14
+ """Search for pattern in files."""
15
+ resolved = resolve_path(path)
16
+ if not resolved.exists():
17
+ return f"Error: not found: {resolved}"
18
+ flags = re.IGNORECASE if ignore_case else 0
19
+ try:
20
+ regex = re.compile(pattern, flags)
21
+ except re.error as e:
22
+ return f"Error: invalid regex: {e}"
23
+ if resolved.is_file():
24
+ files = [resolved]
25
+ else:
26
+ files = sorted(resolved.rglob(file_pattern) if recursive else resolved.glob(file_pattern))
27
+ files = [f for f in files if f.is_file() and not any(p.startswith(".") for p in f.parts)]
28
+ results = []
29
+ for f in files:
30
+ content = read_text(f)
31
+ if not content:
32
+ continue
33
+ for i, line in enumerate(content.splitlines(), 1):
34
+ if regex.search(line):
35
+ rel = f.relative_to(resolved) if resolved.is_dir() else f.name
36
+ results.append(f"{rel}:{i}: {line}")
37
+ if len(results) >= max_results:
38
+ break
39
+ if len(results) >= max_results:
40
+ break
41
+ if not results:
42
+ return f"No matches for '{pattern}'"
43
+ return f"{len(results)} matches for '{pattern}':\n" + "\n".join(results)
44
+
45
+
46
+ def glob_search(pattern: str, path: str = ".") -> str:
47
+ """Find files by glob pattern."""
48
+ resolved = resolve_path(path)
49
+ if not resolved.is_dir():
50
+ return f"Error: not a directory: {resolved}"
51
+ matches = sorted(resolved.glob(pattern))[:100]
52
+ if not matches:
53
+ return f"No matches for '{pattern}'"
54
+ lines = [str(m.relative_to(resolved)) for m in matches]
55
+ return f"{len(matches)} matches:\n" + "\n".join(lines)
@@ -0,0 +1,92 @@
1
+ """Shell execution: exec, cd, cwd, which, env."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import shutil
8
+ import signal
9
+
10
+ from ._state import check_command, check_path, get_custom_env, get_working_dir, resolve_path, set_working_dir
11
+
12
+
13
+ async def shell_exec(command: str, timeout: int = 120, working_dir: str | None = None) -> str:
14
+ """Execute a shell command. Returns stdout, stderr, exit code."""
15
+ if err := check_command(command):
16
+ return err
17
+ cwd = resolve_path(working_dir) if working_dir else get_working_dir()
18
+ if err := check_path(cwd):
19
+ return err
20
+ if not cwd.is_dir():
21
+ return f"Error: directory not found: {cwd}"
22
+ try:
23
+ proc = await asyncio.create_subprocess_shell(
24
+ command,
25
+ stdout=asyncio.subprocess.PIPE,
26
+ stderr=asyncio.subprocess.PIPE,
27
+ cwd=cwd,
28
+ start_new_session=True,
29
+ )
30
+ try:
31
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
32
+ except asyncio.TimeoutError:
33
+ os.killpg(proc.pid, signal.SIGTERM)
34
+ await proc.wait()
35
+ return f"$ {command}\n\nTimeout after {timeout}s"
36
+ parts = [f"$ {command}"]
37
+ if stdout:
38
+ parts.append(stdout.decode("utf-8", errors="replace"))
39
+ if stderr:
40
+ parts.append(f"[STDERR]\n{stderr.decode('utf-8', errors='replace')}")
41
+ if proc.returncode:
42
+ parts.append(f"[Exit {proc.returncode}]")
43
+ if not stdout and not stderr:
44
+ parts.append("(no output)")
45
+ return "\n".join(parts)
46
+ except Exception as e:
47
+ return f"$ {command}\n\nError: {e}"
48
+
49
+
50
+ def cd(path: str) -> str:
51
+ """Change working directory."""
52
+ resolved = resolve_path(path)
53
+ if err := check_path(resolved):
54
+ return err
55
+ if not resolved.is_dir():
56
+ return f"Error: not a directory: {resolved}"
57
+ set_working_dir(resolved)
58
+ return str(resolved)
59
+
60
+
61
+ def cwd() -> str:
62
+ """Current working directory."""
63
+ return str(get_working_dir())
64
+
65
+
66
+ def which(command: str) -> str:
67
+ """Full path of a command."""
68
+ return shutil.which(command) or f"'{command}' not found in PATH"
69
+
70
+
71
+ def env(name: str = "") -> str:
72
+ """Show environment variables. Without name: custom vars only."""
73
+ custom = get_custom_env()
74
+ if name:
75
+ if name in custom:
76
+ return f"{name}={custom[name]} (custom)"
77
+ val = os.environ.get(name)
78
+ return f"{name}={val}" if val else f"'{name}' not set"
79
+ if not custom:
80
+ return "No custom variables set."
81
+ return "\n".join(f" {k}={v}" for k, v in sorted(custom.items()))
82
+
83
+
84
+ def set_env(name: str, value: str = "") -> str:
85
+ """Set or delete (value='') an environment variable."""
86
+ custom = get_custom_env()
87
+ if not value:
88
+ removed = custom.pop(name, None)
89
+ return f"Deleted '{name}'" if removed else f"'{name}' was not set"
90
+ custom[name] = value
91
+ os.environ[name] = value
92
+ return f"{name}={value}"
@@ -0,0 +1,82 @@
1
+ """System diagnostics: ps, sysinfo, ports, disk usage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import platform
7
+
8
+ from ._state import check_path, resolve_path, run
9
+
10
+
11
+ def ps(filter: str = "") -> str:
12
+ """Running processes. Optional filter by name."""
13
+ lines = run(["ps", "aux", "--sort=-pcpu"], timeout=5).splitlines()
14
+ if filter:
15
+ header = lines[0] if lines else ""
16
+ matched = [ln for ln in lines[1:] if filter.lower() in ln.lower()][:50]
17
+ return (header + "\n" + "\n".join(matched)) if matched else f"No processes matching '{filter}'"
18
+ return "\n".join(lines[:31])
19
+
20
+
21
+ def sysinfo() -> str:
22
+ """System overview: OS, CPU, memory, disk, uptime, load."""
23
+ parts = [
24
+ f"Host: {platform.node()}",
25
+ f"OS: {platform.system()} {platform.release()}",
26
+ ]
27
+ try:
28
+ with open("/proc/cpuinfo") as f:
29
+ cpus = [ln for ln in f if ln.startswith("model name")]
30
+ if cpus:
31
+ parts.append(f"CPU: {cpus[0].split(':')[1].strip()} ({len(cpus)} cores)")
32
+ except OSError:
33
+ pass
34
+ try:
35
+ with open("/proc/meminfo") as f:
36
+ mem = {}
37
+ for ln in f:
38
+ k, v = ln.split(":")
39
+ mem[k.strip()] = int(v.strip().split()[0])
40
+ parts.append(f"Memory: {mem.get('MemAvailable', 0) // 1024}M free / {mem.get('MemTotal', 0) // 1024}M")
41
+ except OSError:
42
+ pass
43
+ try:
44
+ st = os.statvfs("/")
45
+ parts.append(f"Disk: {st.f_bavail * st.f_frsize / 1024**3:.1f}G free / {st.f_blocks * st.f_frsize / 1024**3:.1f}G")
46
+ except OSError:
47
+ pass
48
+ try:
49
+ with open("/proc/uptime") as f:
50
+ secs = int(float(f.read().split()[0]))
51
+ d, r = divmod(secs, 86400)
52
+ h, r = divmod(r, 3600)
53
+ parts.append(f"Uptime: {d}d {h}h {r // 60}m")
54
+ except OSError:
55
+ pass
56
+ try:
57
+ l1, l5, l15 = os.getloadavg()
58
+ parts.append(f"Load: {l1:.2f} {l5:.2f} {l15:.2f}")
59
+ except OSError:
60
+ pass
61
+ return "\n".join(parts)
62
+
63
+
64
+ def port_check(port: int = 0) -> str:
65
+ """What's listening on a port (or all ports if port=0)."""
66
+ cmd = ["ss", "-tlnp"]
67
+ if port:
68
+ cmd.append(f"sport = :{port}")
69
+ result = run(cmd, timeout=5)
70
+ if port and len(result.splitlines()) <= 1:
71
+ return f"Nothing on port {port}"
72
+ return result
73
+
74
+
75
+ def disk_usage(path: str = ".") -> str:
76
+ """Disk usage of directory."""
77
+ resolved = resolve_path(path)
78
+ if err := check_path(resolved):
79
+ return err
80
+ if not resolved.is_dir():
81
+ return f"Error: not a directory: {resolved}"
82
+ return run(["du", "-sh", "--max-depth=1", str(resolved)], timeout=30)
@@ -0,0 +1,55 @@
1
+ """Shell tools — re-export facade.
2
+
3
+ Imports all tool functions from submodules so that
4
+ ``from . import tools`` in __init__.py keeps working.
5
+
6
+ v4.0: Git, HTTP, systemd, pip removed — see mcp-git-tools, mcp-http-tools,
7
+ mcp-python-tools, mcp-systemd-tools.
8
+ """
9
+
10
+ from ._history import record as _record
11
+ from ._state import (
12
+ check_command as _check_command,
13
+ check_command as _check_command_allowed,
14
+ check_path as _check_path,
15
+ format_size as _format_size,
16
+ read_text as _read_text,
17
+ resolve_path,
18
+ run as _run,
19
+ set_security_boundaries,
20
+ set_working_dir,
21
+ )
22
+ from .editor import diff_preview, find_replace, str_replace
23
+ from .filesystem import (
24
+ file_append,
25
+ file_copy,
26
+ file_delete,
27
+ file_info,
28
+ file_list,
29
+ file_move,
30
+ file_read,
31
+ file_write,
32
+ head,
33
+ tail,
34
+ tree,
35
+ )
36
+ from .search import glob_search, grep
37
+ from .shell import cd, cwd, env, set_env, shell_exec, which
38
+ from .system import disk_usage, port_check, ps, sysinfo
39
+
40
+ __all__ = [
41
+ # state
42
+ "set_working_dir", "set_security_boundaries", "resolve_path",
43
+ # filesystem
44
+ "file_read", "file_write", "file_append", "file_list",
45
+ "file_delete", "file_move", "file_copy", "file_info",
46
+ "head", "tail", "tree",
47
+ # editor
48
+ "str_replace", "diff_preview", "find_replace",
49
+ # search
50
+ "grep", "glob_search",
51
+ # shell
52
+ "shell_exec", "cd", "cwd", "which", "env", "set_env",
53
+ # system
54
+ "ps", "sysinfo", "port_check", "disk_usage",
55
+ ]