claxcode 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- claxcode/__init__.py +3 -0
- claxcode/api/__init__.py +1 -0
- claxcode/api/chat.py +51 -0
- claxcode/api/session.py +66 -0
- claxcode/api/subagents.py +82 -0
- claxcode/api/tools.py +38 -0
- claxcode/cli.py +1214 -0
- claxcode/cli_completion.py +174 -0
- claxcode/config.py +54 -0
- claxcode/core/__init__.py +1 -0
- claxcode/core/agent.py +327 -0
- claxcode/core/checkpoint.py +100 -0
- claxcode/core/context.py +114 -0
- claxcode/core/models.py +446 -0
- claxcode/extensions/__init__.py +1 -0
- claxcode/extensions/hooks.py +131 -0
- claxcode/extensions/mcp.py +13 -0
- claxcode/extensions/plugins.py +10 -0
- claxcode/extensions/skills.py +205 -0
- claxcode/main.py +55 -0
- claxcode/memory/__init__.py +23 -0
- claxcode/memory/auto_memory.py +68 -0
- claxcode/memory/long_term.py +196 -0
- claxcode/memory/project_context.py +20 -0
- claxcode/memory/project_memory.py +194 -0
- claxcode/memory/session_store.py +206 -0
- claxcode/models/__init__.py +1 -0
- claxcode/models/session.py +25 -0
- claxcode/models/subagent.py +25 -0
- claxcode/models/tool.py +24 -0
- claxcode/security/__init__.py +1 -0
- claxcode/security/audit.py +54 -0
- claxcode/security/permissions.py +29 -0
- claxcode/skills/bug-fix/SKILL.md +33 -0
- claxcode/skills/code-review/SKILL.md +35 -0
- claxcode/skills/python-refactor/SKILL.md +22 -0
- claxcode/skills/task-workflow/SKILL.md +38 -0
- claxcode/subagents/__init__.py +1 -0
- claxcode/subagents/base.py +108 -0
- claxcode/subagents/executor.py +16 -0
- claxcode/subagents/explore.py +25 -0
- claxcode/subagents/planner.py +25 -0
- claxcode/tools/__init__.py +14 -0
- claxcode/tools/admin.py +170 -0
- claxcode/tools/base.py +19 -0
- claxcode/tools/browser.py +499 -0
- claxcode/tools/code_intelligence.py +68 -0
- claxcode/tools/custom_example.py +76 -0
- claxcode/tools/execution.py +114 -0
- claxcode/tools/file_operations.py +287 -0
- claxcode/tools/memory_tool.py +29 -0
- claxcode/tools/registry.py +35 -0
- claxcode/tools/search.py +157 -0
- claxcode/tools/ssh.py +534 -0
- claxcode/tools/web.py +83 -0
- claxcode-0.1.1.dist-info/METADATA +359 -0
- claxcode-0.1.1.dist-info/RECORD +60 -0
- claxcode-0.1.1.dist-info/WHEEL +4 -0
- claxcode-0.1.1.dist-info/entry_points.txt +3 -0
- claxcode-0.1.1.dist-info/licenses/LICENSE +201 -0
claxcode/tools/ssh.py
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
"""SSH tools for remote server management using paramiko."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from io import StringIO
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import paramiko
|
|
10
|
+
|
|
11
|
+
from claxcode.tools.base import Tool
|
|
12
|
+
from claxcode.tools.execution import is_tool_cancelled
|
|
13
|
+
from claxcode.tools.registry import tool
|
|
14
|
+
|
|
15
|
+
# 危险命令列表
|
|
16
|
+
DANGEROUS_COMMANDS = [
|
|
17
|
+
# 系统破坏性命令
|
|
18
|
+
"rm -rf /",
|
|
19
|
+
"rm -rf /*",
|
|
20
|
+
"mkfs.",
|
|
21
|
+
"dd if=/dev/zero",
|
|
22
|
+
":(){ :|:& };:", # fork bomb
|
|
23
|
+
"> /dev/sda",
|
|
24
|
+
"mv / /dev/null",
|
|
25
|
+
|
|
26
|
+
# 权限提升相关
|
|
27
|
+
"chmod -R 777 /",
|
|
28
|
+
"chmod 777 /",
|
|
29
|
+
|
|
30
|
+
# 网络相关危险操作
|
|
31
|
+
"iptables -F",
|
|
32
|
+
"iptables --flush",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_dangerous_command(command: str) -> tuple[bool, str]:
|
|
37
|
+
"""检查命令是否包含危险操作.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
command: 要检查的命令
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
(是否危险, 原因)
|
|
44
|
+
"""
|
|
45
|
+
command_lower = command.lower().strip()
|
|
46
|
+
|
|
47
|
+
# 检查危险命令
|
|
48
|
+
for dangerous in DANGEROUS_COMMANDS:
|
|
49
|
+
if dangerous.lower() in command_lower:
|
|
50
|
+
return True, f"命令包含危险操作: {dangerous}"
|
|
51
|
+
|
|
52
|
+
# 检查删除根目录或系统目录
|
|
53
|
+
dangerous_patterns = [
|
|
54
|
+
(r'rm\s+-rf?\s+/\s', "删除根目录"),
|
|
55
|
+
(r'rm\s+-rf?\s+/boot', "删除启动目录"),
|
|
56
|
+
(r'rm\s+-rf?\s+/etc', "删除配置目录"),
|
|
57
|
+
(r'rm\s+-rf?\s+/usr', "删除系统目录"),
|
|
58
|
+
(r'rm\s+-rf?\s+/bin', "删除二进制目录"),
|
|
59
|
+
(r'rm\s+-rf?\s+/sbin', "删除系统二进制目录"),
|
|
60
|
+
(r'rm\s+-rf?\s+/lib', "删除库目录"),
|
|
61
|
+
(r'rm\s+-rf?\s+/lib64', "删除库目录"),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
import re
|
|
65
|
+
for pattern, reason in dangerous_patterns:
|
|
66
|
+
if re.search(pattern, command_lower):
|
|
67
|
+
return True, f"命令包含危险操作: {reason}"
|
|
68
|
+
|
|
69
|
+
return False, ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class SSHConnectionManager:
|
|
73
|
+
"""管理 SSH 连接的单例类."""
|
|
74
|
+
|
|
75
|
+
_instance = None
|
|
76
|
+
_connections: dict[str, paramiko.SSHClient] = {}
|
|
77
|
+
|
|
78
|
+
def __new__(cls) -> "SSHConnectionManager":
|
|
79
|
+
if cls._instance is None:
|
|
80
|
+
cls._instance = super().__new__(cls)
|
|
81
|
+
return cls._instance
|
|
82
|
+
|
|
83
|
+
def get_connection(self, host: str, port: int, username: str) -> paramiko.SSHClient | None:
|
|
84
|
+
"""获取已存在的连接."""
|
|
85
|
+
key = f"{username}@{host}:{port}"
|
|
86
|
+
return self._connections.get(key)
|
|
87
|
+
|
|
88
|
+
def set_connection(self, host: str, port: int, username: str, client: paramiko.SSHClient) -> None:
|
|
89
|
+
"""保存连接."""
|
|
90
|
+
key = f"{username}@{host}:{port}"
|
|
91
|
+
self._connections[key] = client
|
|
92
|
+
|
|
93
|
+
def close_connection(self, host: str, port: int, username: str) -> None:
|
|
94
|
+
"""关闭并移除连接."""
|
|
95
|
+
key = f"{username}@{host}:{port}"
|
|
96
|
+
if key in self._connections:
|
|
97
|
+
try:
|
|
98
|
+
self._connections[key].close()
|
|
99
|
+
except Exception:
|
|
100
|
+
pass
|
|
101
|
+
del self._connections[key]
|
|
102
|
+
|
|
103
|
+
def close_all(self) -> None:
|
|
104
|
+
"""关闭所有连接."""
|
|
105
|
+
for client in self._connections.values():
|
|
106
|
+
try:
|
|
107
|
+
client.close()
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
self._connections.clear()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# 全局连接管理器
|
|
114
|
+
_ssh_manager = SSHConnectionManager()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _get_ssh_client(
|
|
118
|
+
host: str,
|
|
119
|
+
port: int,
|
|
120
|
+
username: str,
|
|
121
|
+
password: str | None = None,
|
|
122
|
+
private_key: str | None = None,
|
|
123
|
+
private_key_path: str | None = None,
|
|
124
|
+
) -> paramiko.SSHClient:
|
|
125
|
+
"""创建或获取 SSH 连接.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
host: 主机地址
|
|
129
|
+
port: SSH 端口
|
|
130
|
+
username: 用户名
|
|
131
|
+
password: 密码(可选)
|
|
132
|
+
private_key: 私钥内容字符串(可选)
|
|
133
|
+
private_key_path: 私钥文件路径(可选)
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
SSHClient 实例
|
|
137
|
+
"""
|
|
138
|
+
# 检查是否已有连接
|
|
139
|
+
client = _ssh_manager.get_connection(host, port, username)
|
|
140
|
+
if client is not None:
|
|
141
|
+
try:
|
|
142
|
+
# 测试连接是否仍然有效
|
|
143
|
+
transport = client.get_transport()
|
|
144
|
+
if transport is not None and transport.is_active():
|
|
145
|
+
return client
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
# 连接已断开,移除
|
|
149
|
+
_ssh_manager.close_connection(host, port, username)
|
|
150
|
+
|
|
151
|
+
# 创建新连接
|
|
152
|
+
client = paramiko.SSHClient()
|
|
153
|
+
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
154
|
+
|
|
155
|
+
# 准备私钥
|
|
156
|
+
pkey = None
|
|
157
|
+
if private_key:
|
|
158
|
+
pkey = paramiko.RSAKey.from_private_key(StringIO(private_key))
|
|
159
|
+
elif private_key_path:
|
|
160
|
+
private_key_path = Path(private_key_path).expanduser()
|
|
161
|
+
if private_key_path.exists():
|
|
162
|
+
pkey = paramiko.RSAKey.from_private_key_file(str(private_key_path))
|
|
163
|
+
|
|
164
|
+
# 连接
|
|
165
|
+
if pkey:
|
|
166
|
+
client.connect(
|
|
167
|
+
hostname=host,
|
|
168
|
+
port=port,
|
|
169
|
+
username=username,
|
|
170
|
+
pkey=pkey,
|
|
171
|
+
timeout=30,
|
|
172
|
+
)
|
|
173
|
+
elif password:
|
|
174
|
+
client.connect(
|
|
175
|
+
hostname=host,
|
|
176
|
+
port=port,
|
|
177
|
+
username=username,
|
|
178
|
+
password=password,
|
|
179
|
+
timeout=30,
|
|
180
|
+
)
|
|
181
|
+
else:
|
|
182
|
+
# 尝试使用 SSH agent
|
|
183
|
+
client.connect(
|
|
184
|
+
hostname=host,
|
|
185
|
+
port=port,
|
|
186
|
+
username=username,
|
|
187
|
+
timeout=30,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
_ssh_manager.set_connection(host, port, username, client)
|
|
191
|
+
return client
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@tool
|
|
195
|
+
class SSHConnect(Tool):
|
|
196
|
+
"""建立 SSH 连接到远程服务器."""
|
|
197
|
+
|
|
198
|
+
name = "ssh_connect"
|
|
199
|
+
description = "建立 SSH 连接到远程服务器,支持密码或密钥认证."
|
|
200
|
+
input_schema = {
|
|
201
|
+
"type": "object",
|
|
202
|
+
"properties": {
|
|
203
|
+
"host": {"type": "string", "description": "远程主机地址"},
|
|
204
|
+
"port": {"type": "integer", "default": 22, "description": "SSH 端口"},
|
|
205
|
+
"username": {"type": "string", "description": "用户名"},
|
|
206
|
+
"password": {"type": "string", "description": "密码(可选,与密钥二选一)"},
|
|
207
|
+
"private_key": {"type": "string", "description": "私钥内容字符串(可选)"},
|
|
208
|
+
"private_key_path": {"type": "string", "description": "私钥文件路径(可选,如 ~/.ssh/id_rsa)"},
|
|
209
|
+
},
|
|
210
|
+
"required": ["host", "username"],
|
|
211
|
+
}
|
|
212
|
+
category = "ssh"
|
|
213
|
+
required_permissions = ["execute"]
|
|
214
|
+
|
|
215
|
+
async def execute(
|
|
216
|
+
self,
|
|
217
|
+
host: str,
|
|
218
|
+
port: int = 22,
|
|
219
|
+
username: str = "",
|
|
220
|
+
password: str | None = None,
|
|
221
|
+
private_key: str | None = None,
|
|
222
|
+
private_key_path: str | None = None,
|
|
223
|
+
**kwargs: Any,
|
|
224
|
+
) -> str:
|
|
225
|
+
try:
|
|
226
|
+
client = _get_ssh_client(
|
|
227
|
+
host=host,
|
|
228
|
+
port=port,
|
|
229
|
+
username=username,
|
|
230
|
+
password=password,
|
|
231
|
+
private_key=private_key,
|
|
232
|
+
private_key_path=private_key_path,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# 测试连接
|
|
236
|
+
stdin, stdout, stderr = client.exec_command("echo 'SSH connection successful'")
|
|
237
|
+
output = stdout.read().decode().strip()
|
|
238
|
+
error = stderr.read().decode().strip()
|
|
239
|
+
|
|
240
|
+
if error:
|
|
241
|
+
return f"SSH 连接已建立,但测试命令返回错误: {error}"
|
|
242
|
+
|
|
243
|
+
return f"✅ SSH 连接成功建立到 {username}@{host}:{port}\n测试输出: {output}"
|
|
244
|
+
|
|
245
|
+
except paramiko.AuthenticationException:
|
|
246
|
+
return "❌ 认证失败: 请检查用户名、密码或密钥"
|
|
247
|
+
except paramiko.SSHException as e:
|
|
248
|
+
return f"❌ SSH 错误: {e}"
|
|
249
|
+
except Exception as e:
|
|
250
|
+
return f"❌ 连接失败: {e}"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@tool
|
|
254
|
+
class SSHExecute(Tool):
|
|
255
|
+
"""在远程服务器上执行 Shell 命令."""
|
|
256
|
+
|
|
257
|
+
name = "ssh_execute"
|
|
258
|
+
description = "通过 SSH 在远程服务器上执行 Shell 命令."
|
|
259
|
+
input_schema = {
|
|
260
|
+
"type": "object",
|
|
261
|
+
"properties": {
|
|
262
|
+
"host": {"type": "string", "description": "远程主机地址"},
|
|
263
|
+
"port": {"type": "integer", "default": 22, "description": "SSH 端口"},
|
|
264
|
+
"username": {"type": "string", "description": "用户名"},
|
|
265
|
+
"password": {"type": "string", "description": "密码(可选,如果已连接可省略)"},
|
|
266
|
+
"private_key_path": {"type": "string", "description": "私钥路径(可选)"},
|
|
267
|
+
"command": {"type": "string", "description": "要执行的命令"},
|
|
268
|
+
"cwd": {"type": "string", "default": "", "description": "工作目录(可选)"},
|
|
269
|
+
"timeout": {"type": "integer", "default": 300, "description": "命令超时时间(秒)"},
|
|
270
|
+
},
|
|
271
|
+
"required": ["host", "username", "command"],
|
|
272
|
+
}
|
|
273
|
+
category = "ssh"
|
|
274
|
+
required_permissions = ["execute"]
|
|
275
|
+
|
|
276
|
+
async def execute(
|
|
277
|
+
self,
|
|
278
|
+
host: str,
|
|
279
|
+
port: int = 22,
|
|
280
|
+
username: str = "",
|
|
281
|
+
password: str | None = None,
|
|
282
|
+
private_key_path: str | None = None,
|
|
283
|
+
command: str = "",
|
|
284
|
+
cwd: str = "",
|
|
285
|
+
timeout: int = 300,
|
|
286
|
+
**kwargs: Any,
|
|
287
|
+
) -> str:
|
|
288
|
+
try:
|
|
289
|
+
# 检查危险命令
|
|
290
|
+
is_dangerous, reason = is_dangerous_command(command)
|
|
291
|
+
if is_dangerous:
|
|
292
|
+
return f"❌ 命令被拒绝: {reason}\n如需执行此命令,请使用本地终端手动执行。"
|
|
293
|
+
|
|
294
|
+
# 获取或创建连接
|
|
295
|
+
client = _get_ssh_client(
|
|
296
|
+
host=host,
|
|
297
|
+
port=port,
|
|
298
|
+
username=username,
|
|
299
|
+
password=password,
|
|
300
|
+
private_key_path=private_key_path,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# 构建命令
|
|
304
|
+
full_command = command
|
|
305
|
+
if cwd:
|
|
306
|
+
full_command = f"cd {cwd} && {command}"
|
|
307
|
+
|
|
308
|
+
# 执行命令(增量读取,支持超时和中止)
|
|
309
|
+
return await _run_remote_command(client, full_command, timeout)
|
|
310
|
+
|
|
311
|
+
except paramiko.AuthenticationException:
|
|
312
|
+
return "❌ 认证失败: 请检查用户名、密码或密钥"
|
|
313
|
+
except Exception as e:
|
|
314
|
+
return f"❌ 执行失败: {e}"
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
async def _run_remote_command(client: paramiko.SSHClient, command: str, timeout: int) -> str:
|
|
318
|
+
"""Run a remote command, streaming output incrementally.
|
|
319
|
+
|
|
320
|
+
Polls the channel so the event loop stays responsive: honours the
|
|
321
|
+
timeout and the user-cancel flag, and always returns partial output.
|
|
322
|
+
"""
|
|
323
|
+
transport = client.get_transport()
|
|
324
|
+
if transport is None:
|
|
325
|
+
return "❌ SSH 连接已断开"
|
|
326
|
+
|
|
327
|
+
channel = transport.open_session()
|
|
328
|
+
out_chunks: list[bytes] = []
|
|
329
|
+
err_chunks: list[bytes] = []
|
|
330
|
+
try:
|
|
331
|
+
channel.exec_command(command)
|
|
332
|
+
deadline = time.monotonic() + timeout
|
|
333
|
+
|
|
334
|
+
while not channel.exit_status_ready():
|
|
335
|
+
_drain_channel(channel, out_chunks, err_chunks)
|
|
336
|
+
if is_tool_cancelled():
|
|
337
|
+
return _format_remote_result(out_chunks, err_chunks, None, "⏹️ 已被用户中止")
|
|
338
|
+
if time.monotonic() > deadline:
|
|
339
|
+
return _format_remote_result(out_chunks, err_chunks, None, f"⏱️ 超时({timeout}秒)")
|
|
340
|
+
await asyncio.sleep(0.1)
|
|
341
|
+
|
|
342
|
+
# 命令已退出,读取剩余输出
|
|
343
|
+
for _ in range(100):
|
|
344
|
+
_drain_channel(channel, out_chunks, err_chunks)
|
|
345
|
+
if not channel.recv_ready() and not channel.recv_stderr_ready():
|
|
346
|
+
break
|
|
347
|
+
await asyncio.sleep(0.05)
|
|
348
|
+
return _format_remote_result(out_chunks, err_chunks, channel.recv_exit_status(), "")
|
|
349
|
+
finally:
|
|
350
|
+
channel.close()
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _drain_channel(channel, out_chunks: list[bytes], err_chunks: list[bytes]) -> None:
|
|
354
|
+
"""Read all currently available stdout/stderr data from the channel."""
|
|
355
|
+
while channel.recv_ready():
|
|
356
|
+
out_chunks.append(channel.recv(4096))
|
|
357
|
+
while channel.recv_stderr_ready():
|
|
358
|
+
err_chunks.append(channel.recv_stderr(4096))
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _format_remote_result(
|
|
362
|
+
out_chunks: list[bytes],
|
|
363
|
+
err_chunks: list[bytes],
|
|
364
|
+
exit_code: int | None,
|
|
365
|
+
note: str,
|
|
366
|
+
) -> str:
|
|
367
|
+
output = b"".join(out_chunks).decode("utf-8", errors="replace")
|
|
368
|
+
error = b"".join(err_chunks).decode("utf-8", errors="replace")
|
|
369
|
+
result = []
|
|
370
|
+
if output:
|
|
371
|
+
result.append(f"[stdout]\n{output}")
|
|
372
|
+
if error:
|
|
373
|
+
result.append(f"[stderr]\n{error}")
|
|
374
|
+
if exit_code is not None:
|
|
375
|
+
result.append(f"[exit code: {exit_code}]")
|
|
376
|
+
if note:
|
|
377
|
+
result.append(note)
|
|
378
|
+
return "\n".join(result) if result else "(无输出)"
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@tool
|
|
382
|
+
class SSHUpload(Tool):
|
|
383
|
+
"""上传文件到远程服务器."""
|
|
384
|
+
|
|
385
|
+
name = "ssh_upload"
|
|
386
|
+
description = "通过 SFTP 上传本地文件到远程服务器."
|
|
387
|
+
input_schema = {
|
|
388
|
+
"type": "object",
|
|
389
|
+
"properties": {
|
|
390
|
+
"host": {"type": "string", "description": "远程主机地址"},
|
|
391
|
+
"port": {"type": "integer", "default": 22, "description": "SSH 端口"},
|
|
392
|
+
"username": {"type": "string", "description": "用户名"},
|
|
393
|
+
"password": {"type": "string", "description": "密码(可选)"},
|
|
394
|
+
"private_key_path": {"type": "string", "description": "私钥路径(可选)"},
|
|
395
|
+
"local_path": {"type": "string", "description": "本地文件路径"},
|
|
396
|
+
"remote_path": {"type": "string", "description": "远程目标路径"},
|
|
397
|
+
},
|
|
398
|
+
"required": ["host", "username", "local_path", "remote_path"],
|
|
399
|
+
}
|
|
400
|
+
category = "ssh"
|
|
401
|
+
required_permissions = ["execute"]
|
|
402
|
+
|
|
403
|
+
async def execute(
|
|
404
|
+
self,
|
|
405
|
+
host: str,
|
|
406
|
+
port: int = 22,
|
|
407
|
+
username: str = "",
|
|
408
|
+
password: str | None = None,
|
|
409
|
+
private_key_path: str | None = None,
|
|
410
|
+
local_path: str = "",
|
|
411
|
+
remote_path: str = "",
|
|
412
|
+
**kwargs: Any,
|
|
413
|
+
) -> str:
|
|
414
|
+
try:
|
|
415
|
+
local_file = Path(local_path).expanduser()
|
|
416
|
+
if not local_file.exists():
|
|
417
|
+
return f"❌ 本地文件不存在: {local_path}"
|
|
418
|
+
|
|
419
|
+
# 获取或创建连接
|
|
420
|
+
client = _get_ssh_client(
|
|
421
|
+
host=host,
|
|
422
|
+
port=port,
|
|
423
|
+
username=username,
|
|
424
|
+
password=password,
|
|
425
|
+
private_key_path=private_key_path,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
# 使用 SFTP 上传
|
|
429
|
+
sftp = client.open_sftp()
|
|
430
|
+
try:
|
|
431
|
+
sftp.put(str(local_file), remote_path)
|
|
432
|
+
return f"✅ 文件上传成功\n本地: {local_file}\n远程: {username}@{host}:{remote_path}"
|
|
433
|
+
finally:
|
|
434
|
+
sftp.close()
|
|
435
|
+
|
|
436
|
+
except Exception as e:
|
|
437
|
+
return f"❌ 上传失败: {e}"
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
@tool
|
|
441
|
+
class SSHDownload(Tool):
|
|
442
|
+
"""从远程服务器下载文件."""
|
|
443
|
+
|
|
444
|
+
name = "ssh_download"
|
|
445
|
+
description = "通过 SFTP 从远程服务器下载文件到本地."
|
|
446
|
+
input_schema = {
|
|
447
|
+
"type": "object",
|
|
448
|
+
"properties": {
|
|
449
|
+
"host": {"type": "string", "description": "远程主机地址"},
|
|
450
|
+
"port": {"type": "integer", "default": 22, "description": "SSH 端口"},
|
|
451
|
+
"username": {"type": "string", "description": "用户名"},
|
|
452
|
+
"password": {"type": "string", "description": "密码(可选)"},
|
|
453
|
+
"private_key_path": {"type": "string", "description": "私钥路径(可选)"},
|
|
454
|
+
"remote_path": {"type": "string", "description": "远程文件路径"},
|
|
455
|
+
"local_path": {"type": "string", "description": "本地目标路径(可选,默认为当前目录)"},
|
|
456
|
+
},
|
|
457
|
+
"required": ["host", "username", "remote_path"],
|
|
458
|
+
}
|
|
459
|
+
category = "ssh"
|
|
460
|
+
required_permissions = ["execute"]
|
|
461
|
+
|
|
462
|
+
async def execute(
|
|
463
|
+
self,
|
|
464
|
+
host: str,
|
|
465
|
+
port: int = 22,
|
|
466
|
+
username: str = "",
|
|
467
|
+
password: str | None = None,
|
|
468
|
+
private_key_path: str | None = None,
|
|
469
|
+
remote_path: str = "",
|
|
470
|
+
local_path: str = "",
|
|
471
|
+
**kwargs: Any,
|
|
472
|
+
) -> str:
|
|
473
|
+
try:
|
|
474
|
+
# 确定本地路径
|
|
475
|
+
if local_path:
|
|
476
|
+
local_file = Path(local_path).expanduser()
|
|
477
|
+
else:
|
|
478
|
+
# 使用远程文件名
|
|
479
|
+
local_file = Path(Path(remote_path).name)
|
|
480
|
+
|
|
481
|
+
# 确保父目录存在
|
|
482
|
+
local_file.parent.mkdir(parents=True, exist_ok=True)
|
|
483
|
+
|
|
484
|
+
# 获取或创建连接
|
|
485
|
+
client = _get_ssh_client(
|
|
486
|
+
host=host,
|
|
487
|
+
port=port,
|
|
488
|
+
username=username,
|
|
489
|
+
password=password,
|
|
490
|
+
private_key_path=private_key_path,
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
# 使用 SFTP 下载
|
|
494
|
+
sftp = client.open_sftp()
|
|
495
|
+
try:
|
|
496
|
+
sftp.get(remote_path, str(local_file))
|
|
497
|
+
return f"✅ 文件下载成功\n远程: {username}@{host}:{remote_path}\n本地: {local_file.absolute()}"
|
|
498
|
+
finally:
|
|
499
|
+
sftp.close()
|
|
500
|
+
|
|
501
|
+
except Exception as e:
|
|
502
|
+
return f"❌ 下载失败: {e}"
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
@tool
|
|
506
|
+
class SSHDisconnect(Tool):
|
|
507
|
+
"""断开 SSH 连接."""
|
|
508
|
+
|
|
509
|
+
name = "ssh_disconnect"
|
|
510
|
+
description = "断开与远程服务器的 SSH 连接."
|
|
511
|
+
input_schema = {
|
|
512
|
+
"type": "object",
|
|
513
|
+
"properties": {
|
|
514
|
+
"host": {"type": "string", "description": "远程主机地址"},
|
|
515
|
+
"port": {"type": "integer", "default": 22, "description": "SSH 端口"},
|
|
516
|
+
"username": {"type": "string", "description": "用户名"},
|
|
517
|
+
},
|
|
518
|
+
"required": ["host", "username"],
|
|
519
|
+
}
|
|
520
|
+
category = "ssh"
|
|
521
|
+
required_permissions = ["execute"]
|
|
522
|
+
|
|
523
|
+
async def execute(
|
|
524
|
+
self,
|
|
525
|
+
host: str,
|
|
526
|
+
port: int = 22,
|
|
527
|
+
username: str = "",
|
|
528
|
+
**kwargs: Any,
|
|
529
|
+
) -> str:
|
|
530
|
+
try:
|
|
531
|
+
_ssh_manager.close_connection(host, port, username)
|
|
532
|
+
return f"✅ 已断开与 {username}@{host}:{port} 的连接"
|
|
533
|
+
except Exception as e:
|
|
534
|
+
return f"❌ 断开连接失败: {e}"
|
claxcode/tools/web.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Web tools."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from claxcode.tools.base import Tool
|
|
9
|
+
from claxcode.tools.registry import tool
|
|
10
|
+
|
|
11
|
+
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClaxCode/0.1"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@tool
|
|
15
|
+
class FetchURL(Tool):
|
|
16
|
+
name = "fetch_url"
|
|
17
|
+
description = "获取 URL 的文本内容。"
|
|
18
|
+
input_schema = {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"properties": {
|
|
21
|
+
"url": {"type": "string"},
|
|
22
|
+
"max_length": {"type": "integer", "default": 10000},
|
|
23
|
+
},
|
|
24
|
+
"required": ["url"],
|
|
25
|
+
}
|
|
26
|
+
category = "web"
|
|
27
|
+
required_permissions = ["web"]
|
|
28
|
+
|
|
29
|
+
async def execute(self, url: str, max_length: int = 10000, **kwargs: Any) -> str:
|
|
30
|
+
try:
|
|
31
|
+
async with httpx.AsyncClient(
|
|
32
|
+
timeout=30, headers={"User-Agent": USER_AGENT}
|
|
33
|
+
) as client:
|
|
34
|
+
resp = await client.get(url, follow_redirects=True)
|
|
35
|
+
resp.raise_for_status()
|
|
36
|
+
text = resp.text
|
|
37
|
+
if len(text) > max_length:
|
|
38
|
+
text = text[:max_length] + "\n... [已截断]"
|
|
39
|
+
return text
|
|
40
|
+
except httpx.HTTPStatusError as exc:
|
|
41
|
+
return f"HTTP 错误 {exc.response.status_code}: {url}"
|
|
42
|
+
except Exception as exc:
|
|
43
|
+
return f"获取 {url} 时出错: {exc}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@tool
|
|
47
|
+
class WebSearch(Tool):
|
|
48
|
+
name = "web_search"
|
|
49
|
+
description = "使用 DuckDuckGo 搜索网页(HTML 抓取)。"
|
|
50
|
+
input_schema = {
|
|
51
|
+
"type": "object",
|
|
52
|
+
"properties": {
|
|
53
|
+
"query": {"type": "string"},
|
|
54
|
+
"max_results": {"type": "integer", "default": 5},
|
|
55
|
+
},
|
|
56
|
+
"required": ["query"],
|
|
57
|
+
}
|
|
58
|
+
category = "web"
|
|
59
|
+
required_permissions = ["web"]
|
|
60
|
+
|
|
61
|
+
async def execute(self, query: str, max_results: int = 5, **kwargs: Any) -> str:
|
|
62
|
+
try:
|
|
63
|
+
async with httpx.AsyncClient(
|
|
64
|
+
timeout=30, headers={"User-Agent": USER_AGENT}
|
|
65
|
+
) as client:
|
|
66
|
+
resp = await client.get(
|
|
67
|
+
"https://html.duckduckgo.com/html/", params={"q": query}
|
|
68
|
+
)
|
|
69
|
+
resp.raise_for_status()
|
|
70
|
+
results = []
|
|
71
|
+
for href, title in re.findall(
|
|
72
|
+
r'<a rel="nofollow" class="result__a" href="(.*?)">(.*?)</a>',
|
|
73
|
+
resp.text,
|
|
74
|
+
):
|
|
75
|
+
title = re.sub(r"<.*?>", "", title)
|
|
76
|
+
results.append(f"{title}\n{href}")
|
|
77
|
+
if len(results) >= max_results:
|
|
78
|
+
break
|
|
79
|
+
if not results:
|
|
80
|
+
return "未找到搜索结果。"
|
|
81
|
+
return "\n\n".join(results)
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
return f"搜索错误: {exc}"
|