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.
- mcp_shell_tools-4.0.0/.gitignore +9 -0
- mcp_shell_tools-4.0.0/PKG-INFO +176 -0
- mcp_shell_tools-4.0.0/README.md +159 -0
- mcp_shell_tools-4.0.0/pyproject.toml +34 -0
- mcp_shell_tools-4.0.0/src/mcp_shell_tools/__init__.py +16 -0
- mcp_shell_tools-4.0.0/src/mcp_shell_tools/shell/__init__.py +203 -0
- mcp_shell_tools-4.0.0/src/mcp_shell_tools/shell/_history.py +53 -0
- mcp_shell_tools-4.0.0/src/mcp_shell_tools/shell/_state.py +96 -0
- mcp_shell_tools-4.0.0/src/mcp_shell_tools/shell/editor.py +86 -0
- mcp_shell_tools-4.0.0/src/mcp_shell_tools/shell/filesystem.py +229 -0
- mcp_shell_tools-4.0.0/src/mcp_shell_tools/shell/search.py +55 -0
- mcp_shell_tools-4.0.0/src/mcp_shell_tools/shell/shell.py +92 -0
- mcp_shell_tools-4.0.0/src/mcp_shell_tools/shell/system.py +82 -0
- mcp_shell_tools-4.0.0/src/mcp_shell_tools/shell/tools.py +55 -0
|
@@ -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
|
+
]
|