nexforge-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nexforge/__init__.py +7 -0
- nexforge/__main__.py +6 -0
- nexforge/app.py +38 -0
- nexforge/assets/icons/agent.svg +13 -0
- nexforge/assets/icons/cost.svg +5 -0
- nexforge/assets/icons/edit.svg +4 -0
- nexforge/assets/icons/err.svg +5 -0
- nexforge/assets/icons/file.svg +5 -0
- nexforge/assets/icons/model.svg +7 -0
- nexforge/assets/icons/ok.svg +4 -0
- nexforge/assets/icons/read.svg +8 -0
- nexforge/assets/icons/running.svg +5 -0
- nexforge/assets/icons/search.svg +5 -0
- nexforge/assets/icons/shell.svg +5 -0
- nexforge/assets/icons/thinking.svg +5 -0
- nexforge/assets/icons/web.svg +6 -0
- nexforge/assets/icons/write.svg +4 -0
- nexforge/cli.py +159 -0
- nexforge/config.py +192 -0
- nexforge/core/__init__.py +1 -0
- nexforge/core/agent.py +244 -0
- nexforge/core/jobs.py +97 -0
- nexforge/core/permissions.py +73 -0
- nexforge/core/router.py +62 -0
- nexforge/core/session.py +55 -0
- nexforge/core/types.py +103 -0
- nexforge/icons.py +173 -0
- nexforge/mcp/__init__.py +168 -0
- nexforge/provider/__init__.py +1 -0
- nexforge/provider/balance.py +37 -0
- nexforge/provider/deepseek.py +204 -0
- nexforge/skills/loader.py +102 -0
- nexforge/store/__init__.py +1 -0
- nexforge/store/plan_store.py +112 -0
- nexforge/store/session_store.py +206 -0
- nexforge/theme.tcss +128 -0
- nexforge/tools/__init__.py +6 -0
- nexforge/tools/_paths.py +22 -0
- nexforge/tools/background.py +111 -0
- nexforge/tools/base.py +56 -0
- nexforge/tools/edit_file.py +73 -0
- nexforge/tools/fs_ops.py +125 -0
- nexforge/tools/git.py +89 -0
- nexforge/tools/list_dir.py +49 -0
- nexforge/tools/multi_edit.py +84 -0
- nexforge/tools/read_file.py +51 -0
- nexforge/tools/registry.py +94 -0
- nexforge/tools/search_content.py +90 -0
- nexforge/tools/search_files.py +48 -0
- nexforge/tools/shell.py +60 -0
- nexforge/tools/web.py +40 -0
- nexforge/tools/write_file.py +40 -0
- nexforge/ui/__init__.py +1 -0
- nexforge/ui/screens/__init__.py +1 -0
- nexforge/ui/screens/chat.py +825 -0
- nexforge/ui/widgets/__init__.py +1 -0
- nexforge/ui/widgets/apikey_modal.py +65 -0
- nexforge/ui/widgets/banner.py +18 -0
- nexforge/ui/widgets/confirm_modal.py +58 -0
- nexforge/ui/widgets/diff_modal.py +67 -0
- nexforge/ui/widgets/messages.py +132 -0
- nexforge/ui/widgets/mode_warning.py +57 -0
- nexforge/ui/widgets/multiline_modal.py +59 -0
- nexforge/ui/widgets/multiline_prompt.py +25 -0
- nexforge/ui/widgets/plan_sidebar.py +89 -0
- nexforge/ui/widgets/sidebar.py +99 -0
- nexforge/ui/widgets/statusbar.py +70 -0
- nexforge/web/__init__.py +0 -0
- nexforge/web/server.py +177 -0
- nexforge/web/static/index.html +123 -0
- nexforge_cli-0.1.0.dist-info/METADATA +168 -0
- nexforge_cli-0.1.0.dist-info/RECORD +74 -0
- nexforge_cli-0.1.0.dist-info/WHEEL +4 -0
- nexforge_cli-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""edit_file 工具:SEARCH/REPLACE 精确替换(唯一匹配)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from nexforge.tools._paths import resolve
|
|
6
|
+
from nexforge.tools.base import BaseTool, ToolResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EditFileTool(BaseTool):
|
|
10
|
+
name = "edit_file"
|
|
11
|
+
description = (
|
|
12
|
+
"对文件做一次精确替换:把 search 文本替换为 replace。"
|
|
13
|
+
"search 必须在文件中唯一出现,否则拒绝。search 留空则创建新文件。"
|
|
14
|
+
)
|
|
15
|
+
parameters = {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"properties": {
|
|
18
|
+
"path": {"type": "string", "description": "目标文件路径"},
|
|
19
|
+
"search": {"type": "string", "description": "要查找的原文(需唯一);留空表示新建文件"},
|
|
20
|
+
"replace": {"type": "string", "description": "替换后的文本(新建时为全文)"},
|
|
21
|
+
},
|
|
22
|
+
"required": ["path", "replace"],
|
|
23
|
+
}
|
|
24
|
+
requires_approval = True
|
|
25
|
+
parallel_safe = False
|
|
26
|
+
|
|
27
|
+
def preview(self, args: dict) -> str:
|
|
28
|
+
return f"edit_file({args.get('path', '')})"
|
|
29
|
+
|
|
30
|
+
def run(self, path: str, replace: str, search: str = "") -> ToolResult:
|
|
31
|
+
fp = resolve(path)
|
|
32
|
+
|
|
33
|
+
# 新建文件
|
|
34
|
+
if not search:
|
|
35
|
+
if fp.exists():
|
|
36
|
+
return ToolResult(
|
|
37
|
+
ok=False,
|
|
38
|
+
error=f"文件已存在,无法以空 search 新建:{path}(请提供 search 做替换)",
|
|
39
|
+
)
|
|
40
|
+
try:
|
|
41
|
+
fp.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
fp.write_text(replace, encoding="utf-8")
|
|
43
|
+
except Exception as exc:
|
|
44
|
+
return ToolResult(ok=False, error=f"写入失败:{exc}")
|
|
45
|
+
return ToolResult(ok=True, output=f"已创建 {path}", summary=f"新建 {path}")
|
|
46
|
+
|
|
47
|
+
if not fp.exists():
|
|
48
|
+
return ToolResult(ok=False, error=f"文件不存在:{path}")
|
|
49
|
+
try:
|
|
50
|
+
text = fp.read_text(encoding="utf-8")
|
|
51
|
+
except Exception as exc:
|
|
52
|
+
return ToolResult(ok=False, error=f"读取失败:{exc}")
|
|
53
|
+
|
|
54
|
+
occurrences = text.count(search)
|
|
55
|
+
if occurrences == 0:
|
|
56
|
+
return ToolResult(ok=False, error="未找到 search 文本(需逐字节匹配)")
|
|
57
|
+
if occurrences > 1:
|
|
58
|
+
return ToolResult(
|
|
59
|
+
ok=False,
|
|
60
|
+
error=f"search 文本出现 {occurrences} 次,不唯一;请扩大上下文使其唯一。",
|
|
61
|
+
)
|
|
62
|
+
new_text = text.replace(search, replace, 1)
|
|
63
|
+
try:
|
|
64
|
+
fp.write_text(new_text, encoding="utf-8")
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
return ToolResult(ok=False, error=f"写入失败:{exc}")
|
|
67
|
+
|
|
68
|
+
delta = new_text.count("\n") - text.count("\n")
|
|
69
|
+
return ToolResult(
|
|
70
|
+
ok=True,
|
|
71
|
+
output=f"已修改 {path}(行数变化 {delta:+d})",
|
|
72
|
+
summary=f"编辑 {path}",
|
|
73
|
+
)
|
nexforge/tools/fs_ops.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""文件系统操作工具:create_dir / move / copy / delete。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
|
|
7
|
+
from nexforge.tools._paths import resolve
|
|
8
|
+
from nexforge.tools.base import BaseTool, ToolResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CreateDirTool(BaseTool):
|
|
12
|
+
name = "create_dir"
|
|
13
|
+
description = "创建目录(含必要的父目录)。"
|
|
14
|
+
parameters = {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"properties": {"path": {"type": "string", "description": "目录路径"}},
|
|
17
|
+
"required": ["path"],
|
|
18
|
+
}
|
|
19
|
+
requires_approval = True
|
|
20
|
+
parallel_safe = False
|
|
21
|
+
|
|
22
|
+
def preview(self, args: dict) -> str:
|
|
23
|
+
return f"create_dir({args.get('path', '')})"
|
|
24
|
+
|
|
25
|
+
def run(self, path: str) -> ToolResult:
|
|
26
|
+
try:
|
|
27
|
+
resolve(path).mkdir(parents=True, exist_ok=True)
|
|
28
|
+
except Exception as exc:
|
|
29
|
+
return ToolResult(ok=False, error=f"创建失败:{exc}")
|
|
30
|
+
return ToolResult(ok=True, output=f"已创建目录 {path}", summary=f"mkdir {path}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MoveTool(BaseTool):
|
|
34
|
+
name = "move"
|
|
35
|
+
description = "移动 / 重命名文件或目录。"
|
|
36
|
+
parameters = {
|
|
37
|
+
"type": "object",
|
|
38
|
+
"properties": {
|
|
39
|
+
"source": {"type": "string"},
|
|
40
|
+
"destination": {"type": "string"},
|
|
41
|
+
},
|
|
42
|
+
"required": ["source", "destination"],
|
|
43
|
+
}
|
|
44
|
+
requires_approval = True
|
|
45
|
+
parallel_safe = False
|
|
46
|
+
|
|
47
|
+
def preview(self, args: dict) -> str:
|
|
48
|
+
return f"move({args.get('source', '')} → {args.get('destination', '')})"
|
|
49
|
+
|
|
50
|
+
def run(self, source: str, destination: str) -> ToolResult:
|
|
51
|
+
src = resolve(source)
|
|
52
|
+
dst = resolve(destination)
|
|
53
|
+
if not src.exists():
|
|
54
|
+
return ToolResult(ok=False, error=f"源不存在:{source}")
|
|
55
|
+
try:
|
|
56
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
shutil.move(str(src), str(dst))
|
|
58
|
+
except Exception as exc:
|
|
59
|
+
return ToolResult(ok=False, error=f"移动失败:{exc}")
|
|
60
|
+
return ToolResult(ok=True, output=f"已移动 {source} → {destination}",
|
|
61
|
+
summary=f"move {source}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class CopyTool(BaseTool):
|
|
65
|
+
name = "copy"
|
|
66
|
+
description = "复制文件或目录(目标不存在时才复制)。"
|
|
67
|
+
parameters = {
|
|
68
|
+
"type": "object",
|
|
69
|
+
"properties": {
|
|
70
|
+
"source": {"type": "string"},
|
|
71
|
+
"destination": {"type": "string"},
|
|
72
|
+
},
|
|
73
|
+
"required": ["source", "destination"],
|
|
74
|
+
}
|
|
75
|
+
requires_approval = True
|
|
76
|
+
parallel_safe = False
|
|
77
|
+
|
|
78
|
+
def preview(self, args: dict) -> str:
|
|
79
|
+
return f"copy({args.get('source', '')} → {args.get('destination', '')})"
|
|
80
|
+
|
|
81
|
+
def run(self, source: str, destination: str) -> ToolResult:
|
|
82
|
+
src = resolve(source)
|
|
83
|
+
dst = resolve(destination)
|
|
84
|
+
if not src.exists():
|
|
85
|
+
return ToolResult(ok=False, error=f"源不存在:{source}")
|
|
86
|
+
if dst.exists():
|
|
87
|
+
return ToolResult(ok=False, error=f"目标已存在:{destination}")
|
|
88
|
+
try:
|
|
89
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
if src.is_dir():
|
|
91
|
+
shutil.copytree(str(src), str(dst))
|
|
92
|
+
else:
|
|
93
|
+
shutil.copy2(str(src), str(dst))
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
return ToolResult(ok=False, error=f"复制失败:{exc}")
|
|
96
|
+
return ToolResult(ok=True, output=f"已复制 {source} → {destination}",
|
|
97
|
+
summary=f"copy {source}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class DeleteTool(BaseTool):
|
|
101
|
+
name = "delete"
|
|
102
|
+
description = "删除文件或目录(目录递归删除)。危险操作,需审批。"
|
|
103
|
+
parameters = {
|
|
104
|
+
"type": "object",
|
|
105
|
+
"properties": {"path": {"type": "string"}},
|
|
106
|
+
"required": ["path"],
|
|
107
|
+
}
|
|
108
|
+
requires_approval = True
|
|
109
|
+
parallel_safe = False
|
|
110
|
+
|
|
111
|
+
def preview(self, args: dict) -> str:
|
|
112
|
+
return f"delete({args.get('path', '')})"
|
|
113
|
+
|
|
114
|
+
def run(self, path: str) -> ToolResult:
|
|
115
|
+
fp = resolve(path)
|
|
116
|
+
if not fp.exists():
|
|
117
|
+
return ToolResult(ok=False, error=f"不存在:{path}")
|
|
118
|
+
try:
|
|
119
|
+
if fp.is_dir():
|
|
120
|
+
shutil.rmtree(str(fp))
|
|
121
|
+
else:
|
|
122
|
+
fp.unlink()
|
|
123
|
+
except Exception as exc:
|
|
124
|
+
return ToolResult(ok=False, error=f"删除失败:{exc}")
|
|
125
|
+
return ToolResult(ok=True, output=f"已删除 {path}", summary=f"delete {path}")
|
nexforge/tools/git.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Git 工具:git_status / git_diff(只读)、git_commit(审批)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
|
|
7
|
+
from nexforge.tools._paths import truncate
|
|
8
|
+
from nexforge.tools.base import BaseTool, ToolResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _git(args: list[str], timeout: int = 20) -> tuple[int, str]:
|
|
12
|
+
try:
|
|
13
|
+
proc = subprocess.run(
|
|
14
|
+
["git", *args],
|
|
15
|
+
stdin=subprocess.DEVNULL, # 避免继承无效句柄(pytest/无 tty 场景)
|
|
16
|
+
capture_output=True,
|
|
17
|
+
text=True,
|
|
18
|
+
timeout=timeout,
|
|
19
|
+
encoding="utf-8",
|
|
20
|
+
errors="replace",
|
|
21
|
+
)
|
|
22
|
+
return proc.returncode, (proc.stdout or "") + (proc.stderr or "")
|
|
23
|
+
except FileNotFoundError:
|
|
24
|
+
return 127, "未找到 git 命令"
|
|
25
|
+
except subprocess.TimeoutExpired:
|
|
26
|
+
return 124, "git 命令超时"
|
|
27
|
+
except OSError as exc:
|
|
28
|
+
return 1, f"git 执行失败:{exc}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GitStatusTool(BaseTool):
|
|
32
|
+
name = "git_status"
|
|
33
|
+
description = "查看 git 工作区状态(git status --short + 当前分支)。"
|
|
34
|
+
parameters = {"type": "object", "properties": {}}
|
|
35
|
+
|
|
36
|
+
def run(self) -> ToolResult:
|
|
37
|
+
code, out = _git(["status", "--short", "--branch"])
|
|
38
|
+
if code != 0:
|
|
39
|
+
return ToolResult(ok=False, error=out.strip() or "git status 失败")
|
|
40
|
+
return ToolResult(
|
|
41
|
+
ok=True, output=truncate(out.strip()) or "(工作区干净)", summary="git status"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class GitDiffTool(BaseTool):
|
|
46
|
+
name = "git_diff"
|
|
47
|
+
description = "查看 git 差异。默认工作区未暂存改动;staged=true 看已暂存。"
|
|
48
|
+
parameters = {
|
|
49
|
+
"type": "object",
|
|
50
|
+
"properties": {
|
|
51
|
+
"staged": {"type": "boolean", "description": "是否看已暂存改动"},
|
|
52
|
+
"path": {"type": "string", "description": "限定路径(可选)"},
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
def run(self, staged: bool = False, path: str = "") -> ToolResult:
|
|
57
|
+
args = ["diff"]
|
|
58
|
+
if staged:
|
|
59
|
+
args.append("--staged")
|
|
60
|
+
if path:
|
|
61
|
+
args.append(path)
|
|
62
|
+
code, out = _git(args)
|
|
63
|
+
if code != 0:
|
|
64
|
+
return ToolResult(ok=False, error=out.strip() or "git diff 失败")
|
|
65
|
+
return ToolResult(ok=True, output=truncate(out) or "(无差异)", summary="git diff")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class GitCommitTool(BaseTool):
|
|
69
|
+
name = "git_commit"
|
|
70
|
+
description = "暂存全部改动并提交(git add -A && git commit -m)。需审批。"
|
|
71
|
+
parameters = {
|
|
72
|
+
"type": "object",
|
|
73
|
+
"properties": {"message": {"type": "string", "description": "提交信息"}},
|
|
74
|
+
"required": ["message"],
|
|
75
|
+
}
|
|
76
|
+
requires_approval = True
|
|
77
|
+
parallel_safe = False
|
|
78
|
+
|
|
79
|
+
def preview(self, args: dict) -> str:
|
|
80
|
+
return f"git_commit({args.get('message', '')[:40]!r})"
|
|
81
|
+
|
|
82
|
+
def run(self, message: str) -> ToolResult:
|
|
83
|
+
code, out = _git(["add", "-A"])
|
|
84
|
+
if code != 0:
|
|
85
|
+
return ToolResult(ok=False, error=f"git add 失败:{out.strip()}")
|
|
86
|
+
code, out = _git(["commit", "-m", message])
|
|
87
|
+
if code != 0:
|
|
88
|
+
return ToolResult(ok=False, error=out.strip() or "git commit 失败")
|
|
89
|
+
return ToolResult(ok=True, output=truncate(out.strip()), summary="git commit")
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""list_dir 工具:列出目录内容。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from nexforge.tools._paths import resolve, truncate
|
|
6
|
+
from nexforge.tools.base import BaseTool, ToolResult
|
|
7
|
+
|
|
8
|
+
_SKIP = {".git", "__pycache__", ".venv", "node_modules", ".mypy_cache", ".pytest_cache"}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ListDirTool(BaseTool):
|
|
12
|
+
name = "list_dir"
|
|
13
|
+
description = "列出目录内容。recursive=true 时递归展示(跳过依赖/缓存目录)。"
|
|
14
|
+
parameters = {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"properties": {
|
|
17
|
+
"path": {"type": "string", "description": "目录路径,默认当前目录"},
|
|
18
|
+
"recursive": {"type": "boolean", "description": "是否递归,默认 false"},
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
def run(self, path: str = ".", recursive: bool = False) -> ToolResult:
|
|
23
|
+
dp = resolve(path)
|
|
24
|
+
if not dp.exists():
|
|
25
|
+
return ToolResult(ok=False, error=f"目录不存在:{path}")
|
|
26
|
+
if not dp.is_dir():
|
|
27
|
+
return ToolResult(ok=False, error=f"不是目录:{path}")
|
|
28
|
+
|
|
29
|
+
lines: list[str] = []
|
|
30
|
+
count = 0
|
|
31
|
+
if recursive:
|
|
32
|
+
for child in sorted(dp.rglob("*")):
|
|
33
|
+
if any(part in _SKIP for part in child.relative_to(dp).parts):
|
|
34
|
+
continue
|
|
35
|
+
rel = child.relative_to(dp).as_posix()
|
|
36
|
+
lines.append(rel + ("/" if child.is_dir() else ""))
|
|
37
|
+
count += 1
|
|
38
|
+
else:
|
|
39
|
+
for child in sorted(dp.iterdir()):
|
|
40
|
+
if child.name in _SKIP:
|
|
41
|
+
continue
|
|
42
|
+
lines.append(child.name + ("/" if child.is_dir() else ""))
|
|
43
|
+
count += 1
|
|
44
|
+
|
|
45
|
+
return ToolResult(
|
|
46
|
+
ok=True,
|
|
47
|
+
output=truncate("\n".join(lines) or "(空目录)"),
|
|
48
|
+
summary=f"{path} — {count} 项",
|
|
49
|
+
)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""multi_edit 工具:跨文件原子批量 SEARCH/REPLACE。
|
|
2
|
+
|
|
3
|
+
所有编辑先校验(search 唯一匹配),全部通过才落盘;任一失败则全部不改。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from nexforge.tools._paths import resolve
|
|
9
|
+
from nexforge.tools.base import BaseTool, ToolResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MultiEditTool(BaseTool):
|
|
13
|
+
name = "multi_edit"
|
|
14
|
+
description = (
|
|
15
|
+
"跨一个或多个文件原子地做多处 SEARCH/REPLACE。edits 数组按顺序应用,"
|
|
16
|
+
"每个 search 需在其目标文件中唯一。任一失败则全部不改(原子)。"
|
|
17
|
+
)
|
|
18
|
+
parameters = {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"properties": {
|
|
21
|
+
"edits": {
|
|
22
|
+
"type": "array",
|
|
23
|
+
"description": "编辑列表",
|
|
24
|
+
"items": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"properties": {
|
|
27
|
+
"path": {"type": "string"},
|
|
28
|
+
"search": {"type": "string"},
|
|
29
|
+
"replace": {"type": "string"},
|
|
30
|
+
},
|
|
31
|
+
"required": ["path", "search", "replace"],
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"required": ["edits"],
|
|
36
|
+
}
|
|
37
|
+
requires_approval = True
|
|
38
|
+
parallel_safe = False
|
|
39
|
+
|
|
40
|
+
def preview(self, args: dict) -> str:
|
|
41
|
+
edits = args.get("edits", [])
|
|
42
|
+
n = len(edits) if isinstance(edits, list) else 0
|
|
43
|
+
return f"multi_edit({n} 处)"
|
|
44
|
+
|
|
45
|
+
def run(self, edits: list) -> ToolResult:
|
|
46
|
+
if not edits:
|
|
47
|
+
return ToolResult(ok=False, error="edits 为空")
|
|
48
|
+
|
|
49
|
+
# 阶段一:把每个文件读进内存,顺序应用并校验,不落盘
|
|
50
|
+
buffers: dict[str, str] = {}
|
|
51
|
+
for i, ed in enumerate(edits):
|
|
52
|
+
path = ed.get("path", "")
|
|
53
|
+
search = ed.get("search", "")
|
|
54
|
+
replace = ed.get("replace", "")
|
|
55
|
+
fp = resolve(path)
|
|
56
|
+
if path not in buffers:
|
|
57
|
+
if not fp.exists():
|
|
58
|
+
return ToolResult(ok=False, error=f"[edit {i}] 文件不存在:{path}")
|
|
59
|
+
try:
|
|
60
|
+
buffers[path] = fp.read_text(encoding="utf-8")
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
return ToolResult(ok=False, error=f"[edit {i}] 读取失败:{exc}")
|
|
63
|
+
text = buffers[path]
|
|
64
|
+
count = text.count(search)
|
|
65
|
+
if count == 0:
|
|
66
|
+
return ToolResult(ok=False, error=f"[edit {i}] 未找到 search:{path}")
|
|
67
|
+
if count > 1:
|
|
68
|
+
return ToolResult(
|
|
69
|
+
ok=False, error=f"[edit {i}] search 不唯一({count} 次):{path}"
|
|
70
|
+
)
|
|
71
|
+
buffers[path] = text.replace(search, replace, 1)
|
|
72
|
+
|
|
73
|
+
# 阶段二:全部校验通过 → 落盘
|
|
74
|
+
try:
|
|
75
|
+
for path, text in buffers.items():
|
|
76
|
+
resolve(path).write_text(text, encoding="utf-8")
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
return ToolResult(ok=False, error=f"写入失败:{exc}")
|
|
79
|
+
|
|
80
|
+
return ToolResult(
|
|
81
|
+
ok=True,
|
|
82
|
+
output=f"已应用 {len(edits)} 处编辑,涉及 {len(buffers)} 个文件",
|
|
83
|
+
summary=f"multi_edit {len(edits)}处/{len(buffers)}文件",
|
|
84
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""read_file 工具:读取文件内容,支持行范围。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from nexforge.tools._paths import resolve, truncate
|
|
6
|
+
from nexforge.tools.base import BaseTool, ToolResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ReadFileTool(BaseTool):
|
|
10
|
+
name = "read_file"
|
|
11
|
+
description = "读取文本文件内容。可选 start_line/end_line 指定 1-based 闭区间行范围。"
|
|
12
|
+
parameters = {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"properties": {
|
|
15
|
+
"path": {"type": "string", "description": "文件路径(相对当前目录或绝对)"},
|
|
16
|
+
"start_line": {"type": "integer", "description": "起始行(1-based,含)"},
|
|
17
|
+
"end_line": {"type": "integer", "description": "结束行(1-based,含)"},
|
|
18
|
+
},
|
|
19
|
+
"required": ["path"],
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
def preview(self, args: dict) -> str:
|
|
23
|
+
p = args.get("path", "")
|
|
24
|
+
s, e = args.get("start_line"), args.get("end_line")
|
|
25
|
+
if s or e:
|
|
26
|
+
return f"read_file({p}:{s or ''}-{e or ''})"
|
|
27
|
+
return f"read_file({p})"
|
|
28
|
+
|
|
29
|
+
def run(self, path: str, start_line: int | None = None, end_line: int | None = None) -> ToolResult:
|
|
30
|
+
fp = resolve(path)
|
|
31
|
+
if not fp.exists():
|
|
32
|
+
return ToolResult(ok=False, error=f"文件不存在:{path}")
|
|
33
|
+
if fp.is_dir():
|
|
34
|
+
return ToolResult(ok=False, error=f"是目录而非文件:{path}")
|
|
35
|
+
try:
|
|
36
|
+
text = fp.read_text(encoding="utf-8", errors="replace")
|
|
37
|
+
except Exception as exc:
|
|
38
|
+
return ToolResult(ok=False, error=f"读取失败:{exc}")
|
|
39
|
+
|
|
40
|
+
lines = text.splitlines()
|
|
41
|
+
total = len(lines)
|
|
42
|
+
s = max(1, start_line or 1)
|
|
43
|
+
e = min(total, end_line or total)
|
|
44
|
+
chosen = lines[s - 1 : e]
|
|
45
|
+
width = len(str(e))
|
|
46
|
+
body = "\n".join(f"{i + s:>{width}} | {ln}" for i, ln in enumerate(chosen))
|
|
47
|
+
return ToolResult(
|
|
48
|
+
ok=True,
|
|
49
|
+
output=truncate(body),
|
|
50
|
+
summary=f"{path} 第 {s}-{e} 行(共 {total} 行)",
|
|
51
|
+
)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""工具注册表。default_registry() 返回内置核心工具集(M1)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from nexforge.tools.base import BaseTool, ToolResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ToolRegistry:
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self._tools: dict[str, BaseTool] = {}
|
|
13
|
+
|
|
14
|
+
def register(self, tool: BaseTool) -> None:
|
|
15
|
+
self._tools[tool.name] = tool
|
|
16
|
+
|
|
17
|
+
def get(self, name: str) -> BaseTool | None:
|
|
18
|
+
return self._tools.get(name)
|
|
19
|
+
|
|
20
|
+
def names(self) -> list[str]:
|
|
21
|
+
return list(self._tools)
|
|
22
|
+
|
|
23
|
+
def specs(self) -> list[dict]:
|
|
24
|
+
"""所有工具的 OpenAI function-calling schema。"""
|
|
25
|
+
return [t.to_openai() for t in self._tools.values()]
|
|
26
|
+
|
|
27
|
+
def parse_args(self, raw: str) -> dict:
|
|
28
|
+
if not raw or not raw.strip():
|
|
29
|
+
return {}
|
|
30
|
+
try:
|
|
31
|
+
data = json.loads(raw)
|
|
32
|
+
return data if isinstance(data, dict) else {}
|
|
33
|
+
except json.JSONDecodeError:
|
|
34
|
+
return {}
|
|
35
|
+
|
|
36
|
+
def execute(self, name: str, arguments: dict) -> ToolResult:
|
|
37
|
+
tool = self.get(name)
|
|
38
|
+
if tool is None:
|
|
39
|
+
return ToolResult(ok=False, error=f"未知工具:{name}")
|
|
40
|
+
try:
|
|
41
|
+
return tool.run(**arguments)
|
|
42
|
+
except TypeError as exc:
|
|
43
|
+
return ToolResult(ok=False, error=f"参数错误:{exc}")
|
|
44
|
+
except Exception as exc: # 工具内部未捕获异常
|
|
45
|
+
return ToolResult(ok=False, error=f"工具执行异常:{exc}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def default_registry() -> ToolRegistry:
|
|
49
|
+
from nexforge.tools.background import (
|
|
50
|
+
JobOutputTool,
|
|
51
|
+
ListJobsTool,
|
|
52
|
+
RunBackgroundTool,
|
|
53
|
+
StopJobTool,
|
|
54
|
+
WaitForJobTool,
|
|
55
|
+
)
|
|
56
|
+
from nexforge.tools.edit_file import EditFileTool
|
|
57
|
+
from nexforge.tools.fs_ops import CopyTool, CreateDirTool, DeleteTool, MoveTool
|
|
58
|
+
from nexforge.tools.git import GitDiffTool, GitStatusTool
|
|
59
|
+
from nexforge.tools.list_dir import ListDirTool
|
|
60
|
+
from nexforge.tools.multi_edit import MultiEditTool
|
|
61
|
+
from nexforge.tools.read_file import ReadFileTool
|
|
62
|
+
from nexforge.tools.search_content import SearchContentTool
|
|
63
|
+
from nexforge.tools.search_files import SearchFilesTool
|
|
64
|
+
from nexforge.tools.shell import ShellTool
|
|
65
|
+
from nexforge.tools.web import WebFetchTool
|
|
66
|
+
from nexforge.tools.write_file import WriteFileTool
|
|
67
|
+
|
|
68
|
+
reg = ToolRegistry()
|
|
69
|
+
for tool in (
|
|
70
|
+
# 读
|
|
71
|
+
ReadFileTool(), ListDirTool(),
|
|
72
|
+
# 搜索
|
|
73
|
+
SearchContentTool(), SearchFilesTool(),
|
|
74
|
+
# 写
|
|
75
|
+
WriteFileTool(), EditFileTool(), MultiEditTool(),
|
|
76
|
+
# 文件操作
|
|
77
|
+
CreateDirTool(), MoveTool(), CopyTool(), DeleteTool(),
|
|
78
|
+
# Shell + 后台作业
|
|
79
|
+
ShellTool(),
|
|
80
|
+
RunBackgroundTool(), JobOutputTool(), WaitForJobTool(),
|
|
81
|
+
StopJobTool(), ListJobsTool(),
|
|
82
|
+
# Web + Git(只读)
|
|
83
|
+
WebFetchTool(),
|
|
84
|
+
GitStatusTool(), GitDiffTool(),
|
|
85
|
+
):
|
|
86
|
+
reg.register(tool)
|
|
87
|
+
return reg
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def register_git_commit(reg: ToolRegistry) -> None:
|
|
91
|
+
"""按配置开启 git_commit(默认关闭,避免误提交)。"""
|
|
92
|
+
from nexforge.tools.git import GitCommitTool
|
|
93
|
+
|
|
94
|
+
reg.register(GitCommitTool())
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""search_content 工具:内容 grep(支持正则、glob 过滤、上下文)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from nexforge.tools._paths import resolve, truncate
|
|
9
|
+
from nexforge.tools.base import BaseTool, ToolResult
|
|
10
|
+
|
|
11
|
+
_SKIP_DIRS = {".git", "__pycache__", ".venv", "node_modules", ".mypy_cache",
|
|
12
|
+
".pytest_cache", "dist", "build", ".nexforge"}
|
|
13
|
+
_MAX_HITS = 200
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SearchContentTool(BaseTool):
|
|
17
|
+
name = "search_content"
|
|
18
|
+
description = (
|
|
19
|
+
"在文件内容中搜索(正则)。可用 glob 过滤文件名(如 *.py),"
|
|
20
|
+
"context 指定上下文行数。返回 path:line: text。"
|
|
21
|
+
)
|
|
22
|
+
parameters = {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"properties": {
|
|
25
|
+
"pattern": {"type": "string", "description": "正则表达式"},
|
|
26
|
+
"path": {"type": "string", "description": "起始目录,默认当前目录"},
|
|
27
|
+
"glob": {"type": "string", "description": "文件名 glob,如 *.py"},
|
|
28
|
+
"context": {"type": "integer", "description": "上下文行数,默认 0"},
|
|
29
|
+
},
|
|
30
|
+
"required": ["pattern"],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
def preview(self, args: dict) -> str:
|
|
34
|
+
return f"search_content({args.get('pattern', '')!r})"
|
|
35
|
+
|
|
36
|
+
def run(self, pattern: str, path: str = ".", glob: str = "*", context: int = 0) -> ToolResult:
|
|
37
|
+
base = resolve(path)
|
|
38
|
+
if not base.exists():
|
|
39
|
+
return ToolResult(ok=False, error=f"路径不存在:{path}")
|
|
40
|
+
try:
|
|
41
|
+
rx = re.compile(pattern)
|
|
42
|
+
except re.error as exc:
|
|
43
|
+
return ToolResult(ok=False, error=f"正则错误:{exc}")
|
|
44
|
+
|
|
45
|
+
files = [base] if base.is_file() else base.rglob(glob or "*")
|
|
46
|
+
hits: list[str] = []
|
|
47
|
+
n_files = 0
|
|
48
|
+
for fp in files:
|
|
49
|
+
if not fp.is_file():
|
|
50
|
+
continue
|
|
51
|
+
if any(part in _SKIP_DIRS for part in fp.parts):
|
|
52
|
+
continue
|
|
53
|
+
try:
|
|
54
|
+
lines = fp.read_text(encoding="utf-8", errors="ignore").splitlines()
|
|
55
|
+
except Exception:
|
|
56
|
+
continue
|
|
57
|
+
rel = self._rel(fp, base)
|
|
58
|
+
matched_here = False
|
|
59
|
+
for idx, line in enumerate(lines):
|
|
60
|
+
if rx.search(line):
|
|
61
|
+
matched_here = True
|
|
62
|
+
lo = max(0, idx - context)
|
|
63
|
+
hi = min(len(lines), idx + context + 1)
|
|
64
|
+
for j in range(lo, hi):
|
|
65
|
+
sep = ":" if j == idx else "-"
|
|
66
|
+
hits.append(f"{rel}:{j + 1}{sep} {lines[j]}")
|
|
67
|
+
if len(hits) >= _MAX_HITS:
|
|
68
|
+
break
|
|
69
|
+
if len(hits) >= _MAX_HITS:
|
|
70
|
+
break
|
|
71
|
+
if matched_here:
|
|
72
|
+
n_files += 1
|
|
73
|
+
if len(hits) >= _MAX_HITS:
|
|
74
|
+
hits.append(f"… 命中过多,已截断到 {_MAX_HITS} 行")
|
|
75
|
+
break
|
|
76
|
+
|
|
77
|
+
if not hits:
|
|
78
|
+
return ToolResult(ok=True, output="(无匹配)", summary=f"{pattern} — 0 命中")
|
|
79
|
+
return ToolResult(
|
|
80
|
+
ok=True,
|
|
81
|
+
output=truncate("\n".join(hits)),
|
|
82
|
+
summary=f"{pattern} — {n_files} 个文件命中",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _rel(fp: Path, base: Path) -> str:
|
|
87
|
+
try:
|
|
88
|
+
return fp.relative_to(base if base.is_dir() else base.parent).as_posix()
|
|
89
|
+
except ValueError:
|
|
90
|
+
return fp.as_posix()
|