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.
Files changed (74) hide show
  1. nexforge/__init__.py +7 -0
  2. nexforge/__main__.py +6 -0
  3. nexforge/app.py +38 -0
  4. nexforge/assets/icons/agent.svg +13 -0
  5. nexforge/assets/icons/cost.svg +5 -0
  6. nexforge/assets/icons/edit.svg +4 -0
  7. nexforge/assets/icons/err.svg +5 -0
  8. nexforge/assets/icons/file.svg +5 -0
  9. nexforge/assets/icons/model.svg +7 -0
  10. nexforge/assets/icons/ok.svg +4 -0
  11. nexforge/assets/icons/read.svg +8 -0
  12. nexforge/assets/icons/running.svg +5 -0
  13. nexforge/assets/icons/search.svg +5 -0
  14. nexforge/assets/icons/shell.svg +5 -0
  15. nexforge/assets/icons/thinking.svg +5 -0
  16. nexforge/assets/icons/web.svg +6 -0
  17. nexforge/assets/icons/write.svg +4 -0
  18. nexforge/cli.py +159 -0
  19. nexforge/config.py +192 -0
  20. nexforge/core/__init__.py +1 -0
  21. nexforge/core/agent.py +244 -0
  22. nexforge/core/jobs.py +97 -0
  23. nexforge/core/permissions.py +73 -0
  24. nexforge/core/router.py +62 -0
  25. nexforge/core/session.py +55 -0
  26. nexforge/core/types.py +103 -0
  27. nexforge/icons.py +173 -0
  28. nexforge/mcp/__init__.py +168 -0
  29. nexforge/provider/__init__.py +1 -0
  30. nexforge/provider/balance.py +37 -0
  31. nexforge/provider/deepseek.py +204 -0
  32. nexforge/skills/loader.py +102 -0
  33. nexforge/store/__init__.py +1 -0
  34. nexforge/store/plan_store.py +112 -0
  35. nexforge/store/session_store.py +206 -0
  36. nexforge/theme.tcss +128 -0
  37. nexforge/tools/__init__.py +6 -0
  38. nexforge/tools/_paths.py +22 -0
  39. nexforge/tools/background.py +111 -0
  40. nexforge/tools/base.py +56 -0
  41. nexforge/tools/edit_file.py +73 -0
  42. nexforge/tools/fs_ops.py +125 -0
  43. nexforge/tools/git.py +89 -0
  44. nexforge/tools/list_dir.py +49 -0
  45. nexforge/tools/multi_edit.py +84 -0
  46. nexforge/tools/read_file.py +51 -0
  47. nexforge/tools/registry.py +94 -0
  48. nexforge/tools/search_content.py +90 -0
  49. nexforge/tools/search_files.py +48 -0
  50. nexforge/tools/shell.py +60 -0
  51. nexforge/tools/web.py +40 -0
  52. nexforge/tools/write_file.py +40 -0
  53. nexforge/ui/__init__.py +1 -0
  54. nexforge/ui/screens/__init__.py +1 -0
  55. nexforge/ui/screens/chat.py +825 -0
  56. nexforge/ui/widgets/__init__.py +1 -0
  57. nexforge/ui/widgets/apikey_modal.py +65 -0
  58. nexforge/ui/widgets/banner.py +18 -0
  59. nexforge/ui/widgets/confirm_modal.py +58 -0
  60. nexforge/ui/widgets/diff_modal.py +67 -0
  61. nexforge/ui/widgets/messages.py +132 -0
  62. nexforge/ui/widgets/mode_warning.py +57 -0
  63. nexforge/ui/widgets/multiline_modal.py +59 -0
  64. nexforge/ui/widgets/multiline_prompt.py +25 -0
  65. nexforge/ui/widgets/plan_sidebar.py +89 -0
  66. nexforge/ui/widgets/sidebar.py +99 -0
  67. nexforge/ui/widgets/statusbar.py +70 -0
  68. nexforge/web/__init__.py +0 -0
  69. nexforge/web/server.py +177 -0
  70. nexforge/web/static/index.html +123 -0
  71. nexforge_cli-0.1.0.dist-info/METADATA +168 -0
  72. nexforge_cli-0.1.0.dist-info/RECORD +74 -0
  73. nexforge_cli-0.1.0.dist-info/WHEEL +4 -0
  74. 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
+ )
@@ -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()