@raolin2025/claude-code-node 1.0.0
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.
- package/README.md +294 -0
- package/package.json +24 -0
- package/src/core/cli.js +314 -0
- package/src/core/config.js +131 -0
- package/src/core/index.js +9 -0
- package/src/core/query-engine.js +344 -0
- package/src/core/session.js +120 -0
- package/src/core/streaming.js +119 -0
- package/src/core/token-budget.js +88 -0
- package/src/index.js +13 -0
- package/src/mcp/client.js +214 -0
- package/src/mcp/index.js +5 -0
- package/src/mcp/registry.js +176 -0
- package/src/permission/permission.js +37 -0
- package/src/security/bash-guard.js +279 -0
- package/src/security/enhanced-permission.js +310 -0
- package/src/security/index.js +7 -0
- package/src/security/path-guard.js +190 -0
- package/src/security/ssrf-guard.js +178 -0
- package/src/tools/ask-user.js +34 -0
- package/src/tools/bash.js +101 -0
- package/src/tools/file-edit.js +112 -0
- package/src/tools/file-read.js +105 -0
- package/src/tools/file-write.js +57 -0
- package/src/tools/glob.js +113 -0
- package/src/tools/grep.js +117 -0
- package/src/tools/index.js +110 -0
- package/src/tools/web-fetch.js +125 -0
- package/src/tools/web-search.js +75 -0
- package/src/types/index.js +126 -0
- package/src/utils/diff.js +181 -0
- package/src/utils/file-ops.js +124 -0
- package/src/utils/format.js +130 -0
- package/src/utils/index.js +7 -0
- package/src/utils/process.js +112 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileRead 工具 — 读取文件
|
|
3
|
+
* 对应原版: src/tools/FileReadTool/
|
|
4
|
+
*/
|
|
5
|
+
import { readFile, stat } from 'fs/promises'
|
|
6
|
+
import { resolve, isAbsolute } from 'path'
|
|
7
|
+
import { ToolDef } from '../types/index.js'
|
|
8
|
+
import { checkPathSafety } from '../security/path-guard.js'
|
|
9
|
+
|
|
10
|
+
const MAX_LINES = 2000
|
|
11
|
+
const MAX_SIZE_BYTES = 256 * 1024 // 256KB
|
|
12
|
+
|
|
13
|
+
export const fileReadTool = new ToolDef(
|
|
14
|
+
'Read',
|
|
15
|
+
`Read a file from the local filesystem.
|
|
16
|
+
Usage:
|
|
17
|
+
- The file_path must be an absolute path
|
|
18
|
+
- Optionally specify offset (1-based line number) and limit
|
|
19
|
+
- Results use cat -n format with line numbers
|
|
20
|
+
- Supports text files, images (PNG/JPG/GIF/WebP), and PDFs`,
|
|
21
|
+
{
|
|
22
|
+
type: 'object',
|
|
23
|
+
properties: {
|
|
24
|
+
file_path: {
|
|
25
|
+
type: 'string',
|
|
26
|
+
description: 'The absolute path to the file to read',
|
|
27
|
+
},
|
|
28
|
+
offset: {
|
|
29
|
+
type: 'number',
|
|
30
|
+
description: 'Line number to start reading from (1-based)',
|
|
31
|
+
},
|
|
32
|
+
limit: {
|
|
33
|
+
type: 'number',
|
|
34
|
+
description: 'Maximum number of lines to read',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
required: ['file_path'],
|
|
38
|
+
},
|
|
39
|
+
async (input, ctx) => {
|
|
40
|
+
let filePath = input.file_path
|
|
41
|
+
if (!isAbsolute(filePath)) {
|
|
42
|
+
filePath = resolve(ctx.cwd || process.cwd(), filePath)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 路径安全检查
|
|
46
|
+
const pathResult = checkPathSafety(filePath, { cwd: ctx.cwd || process.cwd() })
|
|
47
|
+
if (!pathResult.safe) {
|
|
48
|
+
return `[🚫 路径被安全策略阻止]\n${pathResult.reasons.join('\n')}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const fileStat = await stat(filePath)
|
|
53
|
+
|
|
54
|
+
// 检查文件大小
|
|
55
|
+
if (fileStat.size > MAX_SIZE_BYTES && !input.offset && !input.limit) {
|
|
56
|
+
return `[File too large: ${fileStat.size} bytes > ${MAX_SIZE_BYTES} limit. Use offset/limit to read portions.]`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 图片文件检测
|
|
60
|
+
const ext = filePath.split('.').pop().toLowerCase()
|
|
61
|
+
const imageExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp']
|
|
62
|
+
if (imageExts.includes(ext)) {
|
|
63
|
+
const buf = await readFile(filePath)
|
|
64
|
+
const base64 = buf.toString('base64')
|
|
65
|
+
const mime = ext === 'svg' ? 'image/svg+xml' : `image/${ext === 'jpg' ? 'jpeg' : ext}`
|
|
66
|
+
return `[Image: ${filePath} (${(fileStat.size / 1024).toFixed(1)}KB)] data:${mime};base64,${base64.slice(0, 100)}...`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 文本文件
|
|
70
|
+
const content = await readFile(filePath, 'utf-8')
|
|
71
|
+
const lines = content.split('\n')
|
|
72
|
+
|
|
73
|
+
const offset = input.offset ? Math.max(1, input.offset) - 1 : 0
|
|
74
|
+
const limit = input.limit || MAX_LINES
|
|
75
|
+
const sliced = lines.slice(offset, offset + limit)
|
|
76
|
+
|
|
77
|
+
// cat -n 格式
|
|
78
|
+
const numbered = sliced
|
|
79
|
+
.map((line, i) => {
|
|
80
|
+
const lineNum = String(offset + i + 1).padStart(6, ' ')
|
|
81
|
+
return `${lineNum}\t${line}`
|
|
82
|
+
})
|
|
83
|
+
.join('\n')
|
|
84
|
+
|
|
85
|
+
const totalLines = lines.length
|
|
86
|
+
const readLines = sliced.length
|
|
87
|
+
let result = numbered
|
|
88
|
+
|
|
89
|
+
if (offset > 0 || readLines < totalLines) {
|
|
90
|
+
result += `\n\n[${readLines} of ${totalLines} lines shown (lines ${offset + 1}-${offset + readLines})]`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return result
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (err.code === 'ENOENT') {
|
|
96
|
+
return `[File not found: ${filePath}]`
|
|
97
|
+
}
|
|
98
|
+
if (err.code === 'EISDIR') {
|
|
99
|
+
return `[Path is a directory, not a file: ${filePath}. Use Bash with ls to list directory contents.]`
|
|
100
|
+
}
|
|
101
|
+
return `[Error reading file: ${err.message}]`
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
'always-allow'
|
|
105
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileWrite 工具 — 写入/创建文件
|
|
3
|
+
* 对应原版: src/tools/FileWriteTool/
|
|
4
|
+
*/
|
|
5
|
+
import { writeFile, mkdir } from 'fs/promises'
|
|
6
|
+
import { resolve, isAbsolute, dirname } from 'path'
|
|
7
|
+
import { ToolDef } from '../types/index.js'
|
|
8
|
+
import { checkWritePathSafety } from '../security/path-guard.js'
|
|
9
|
+
|
|
10
|
+
export const fileWriteTool = new ToolDef(
|
|
11
|
+
'Write',
|
|
12
|
+
`Write content to a file. Creates the file if it doesn't exist, overwrites if it does.
|
|
13
|
+
Usage:
|
|
14
|
+
- file_path must be an absolute path
|
|
15
|
+
- content is the text to write
|
|
16
|
+
- Parent directories are created automatically
|
|
17
|
+
- This tool will OVERWRITE existing files - use Edit for partial changes`,
|
|
18
|
+
{
|
|
19
|
+
type: 'object',
|
|
20
|
+
properties: {
|
|
21
|
+
file_path: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
description: 'The absolute path to the file to write',
|
|
24
|
+
},
|
|
25
|
+
content: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: 'The content to write to the file',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
required: ['file_path', 'content'],
|
|
31
|
+
},
|
|
32
|
+
async (input, ctx) => {
|
|
33
|
+
let filePath = input.file_path
|
|
34
|
+
if (!isAbsolute(filePath)) {
|
|
35
|
+
filePath = resolve(ctx.cwd || process.cwd(), filePath)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 写入路径安全检查
|
|
39
|
+
const pathResult = checkWritePathSafety(filePath, { cwd: ctx.cwd || process.cwd() })
|
|
40
|
+
if (!pathResult.safe) {
|
|
41
|
+
return `[🚫 路径被安全策略阻止]\n${pathResult.reasons.join('\n')}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
// 自动创建父目录
|
|
46
|
+
await mkdir(dirname(filePath), { recursive: true })
|
|
47
|
+
await writeFile(filePath, input.content, 'utf-8')
|
|
48
|
+
|
|
49
|
+
const lines = input.content.split('\n').length
|
|
50
|
+
const size = Buffer.byteLength(input.content, 'utf-8')
|
|
51
|
+
return `Successfully wrote to ${filePath} (${lines} lines, ${size} bytes)`
|
|
52
|
+
} catch (err) {
|
|
53
|
+
return `[Error writing file: ${err.message}]`
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
'ask'
|
|
57
|
+
)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Glob 工具 — 文件模式搜索
|
|
3
|
+
* 对应原版: src/tools/GlobTool/
|
|
4
|
+
*/
|
|
5
|
+
import { readdir, stat } from 'fs/promises'
|
|
6
|
+
import { resolve, join, isAbsolute } from 'path'
|
|
7
|
+
import { ToolDef } from '../types/index.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 简单的 glob 模式匹配器
|
|
11
|
+
* 支持 * (任意非/字符) 和 ** (任意路径)
|
|
12
|
+
*/
|
|
13
|
+
function globToRegex(pattern) {
|
|
14
|
+
let regex = ''
|
|
15
|
+
let i = 0
|
|
16
|
+
while (i < pattern.length) {
|
|
17
|
+
const ch = pattern[i]
|
|
18
|
+
if (ch === '*' && pattern[i + 1] === '*') {
|
|
19
|
+
if (pattern[i + 2] === '/') {
|
|
20
|
+
regex += '(?:.*/)?'
|
|
21
|
+
i += 3
|
|
22
|
+
} else {
|
|
23
|
+
regex += '.*'
|
|
24
|
+
i += 2
|
|
25
|
+
}
|
|
26
|
+
} else if (ch === '*') {
|
|
27
|
+
regex += '[^/]*'
|
|
28
|
+
i++
|
|
29
|
+
} else if (ch === '?') {
|
|
30
|
+
regex += '[^/]'
|
|
31
|
+
i++
|
|
32
|
+
} else if ('.+^${}()|[]\\'.includes(ch)) {
|
|
33
|
+
regex += '\\' + ch
|
|
34
|
+
i++
|
|
35
|
+
} else {
|
|
36
|
+
regex += ch
|
|
37
|
+
i++
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return new RegExp('^' + regex + '$')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function* walkDir(dir, ignoreDirs = []) {
|
|
44
|
+
try {
|
|
45
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
const fullPath = join(dir, entry.name)
|
|
48
|
+
if (entry.isDirectory()) {
|
|
49
|
+
// 跳过常见的忽略目录
|
|
50
|
+
if (ignoreDirs.includes(entry.name)) continue
|
|
51
|
+
if (entry.name.startsWith('.') && entry.name !== '.claude') continue
|
|
52
|
+
yield* walkDir(fullPath, ignoreDirs)
|
|
53
|
+
} else if (entry.isFile()) {
|
|
54
|
+
yield fullPath
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// 忽略权限错误等
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const globTool = new ToolDef(
|
|
63
|
+
'Glob',
|
|
64
|
+
`Find files matching a glob pattern.
|
|
65
|
+
Usage:
|
|
66
|
+
- pattern supports * (any non-path chars) and ** (any path segment)
|
|
67
|
+
- path is the base directory to search from (default: cwd)
|
|
68
|
+
- Results are returned as a list of file paths`,
|
|
69
|
+
{
|
|
70
|
+
type: 'object',
|
|
71
|
+
properties: {
|
|
72
|
+
pattern: {
|
|
73
|
+
type: 'string',
|
|
74
|
+
description: 'The glob pattern to match (e.g. "**/*.js", "src/**/*.ts")',
|
|
75
|
+
},
|
|
76
|
+
path: {
|
|
77
|
+
type: 'string',
|
|
78
|
+
description: 'The base directory to search from (default: cwd)',
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
required: ['pattern'],
|
|
82
|
+
},
|
|
83
|
+
async (input, ctx) => {
|
|
84
|
+
const baseDir = input.path
|
|
85
|
+
? (isAbsolute(input.path) ? input.path : resolve(ctx.cwd || process.cwd(), input.path))
|
|
86
|
+
: (ctx.cwd || process.cwd())
|
|
87
|
+
|
|
88
|
+
const pattern = input.pattern
|
|
89
|
+
const regex = globToRegex(pattern)
|
|
90
|
+
const ignoreDirs = ['node_modules', '.git', '__pycache__', '.svn', 'dist', 'build', '.next']
|
|
91
|
+
|
|
92
|
+
const matches = []
|
|
93
|
+
const MAX_RESULTS = 200
|
|
94
|
+
|
|
95
|
+
for await (const filePath of walkDir(baseDir, ignoreDirs)) {
|
|
96
|
+
const relativePath = filePath.slice(baseDir.length + 1)
|
|
97
|
+
if (regex.test(relativePath) || regex.test(filePath)) {
|
|
98
|
+
matches.push(filePath)
|
|
99
|
+
if (matches.length >= MAX_RESULTS) {
|
|
100
|
+
matches.push(`... (truncated at ${MAX_RESULTS} results)`)
|
|
101
|
+
break
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (matches.length === 0) {
|
|
107
|
+
return `[No files matching pattern: ${pattern} in ${baseDir}]`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return matches.join('\n')
|
|
111
|
+
},
|
|
112
|
+
'always-allow'
|
|
113
|
+
)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grep 工具 — 内容搜索
|
|
3
|
+
* 对应原版: src/tools/GrepTool/
|
|
4
|
+
* 优先使用 ripgrep (rg),回退到 grep,最后用纯 JS 实现
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from 'child_process'
|
|
7
|
+
import { resolve, isAbsolute } from 'path'
|
|
8
|
+
import { ToolDef } from '../types/index.js'
|
|
9
|
+
|
|
10
|
+
async function grepWithRg(pattern, path, options = {}) {
|
|
11
|
+
const args = ['--line-number', '--color=never']
|
|
12
|
+
if (options.ignore_case) args.push('-i')
|
|
13
|
+
if (options.file_pattern) args.push('--glob', options.file_pattern)
|
|
14
|
+
if (options.context_lines) args.push('-C', String(options.context_lines))
|
|
15
|
+
args.push(pattern, path)
|
|
16
|
+
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const proc = spawn('rg', args, { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
19
|
+
let stdout = ''
|
|
20
|
+
let stderr = ''
|
|
21
|
+
proc.stdout.on('data', d => stdout += d)
|
|
22
|
+
proc.stderr.on('data', d => stderr += d)
|
|
23
|
+
proc.on('close', (code) => {
|
|
24
|
+
// rg exit 1 = no matches, 2 = error
|
|
25
|
+
if (code === 0 || code === 1) resolve(stdout)
|
|
26
|
+
else resolve(`[ripgrep error: ${stderr}]`)
|
|
27
|
+
})
|
|
28
|
+
proc.on('error', () => resolve(null)) // rg not found
|
|
29
|
+
proc.stdin.end()
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function grepWithGrep(pattern, path, options = {}) {
|
|
34
|
+
const args = ['-rn', '--color=never']
|
|
35
|
+
if (options.ignore_case) args.push('-i')
|
|
36
|
+
if (options.file_pattern) args.push('--include', options.file_pattern)
|
|
37
|
+
if (options.context_lines) args.push('-C', String(options.context_lines))
|
|
38
|
+
args.push(pattern, path)
|
|
39
|
+
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const proc = spawn('grep', args, { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
42
|
+
let stdout = ''
|
|
43
|
+
let stderr = ''
|
|
44
|
+
proc.stdout.on('data', d => stdout += d)
|
|
45
|
+
proc.stderr.on('data', d => stderr += d)
|
|
46
|
+
proc.on('close', (code) => {
|
|
47
|
+
if (code === 0 || code === 1) resolve(stdout)
|
|
48
|
+
else resolve(`[grep error: ${stderr}]`)
|
|
49
|
+
})
|
|
50
|
+
proc.on('error', () => resolve(null))
|
|
51
|
+
proc.stdin.end()
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const grepTool = new ToolDef(
|
|
56
|
+
'Grep',
|
|
57
|
+
`Search file contents with a regex pattern.
|
|
58
|
+
Usage:
|
|
59
|
+
- pattern is a regular expression (RE2 syntax for rg, BRE/ERE for grep)
|
|
60
|
+
- path is the directory to search in (default: cwd)
|
|
61
|
+
- Use ignore_case for case-insensitive search
|
|
62
|
+
- Use file_pattern to filter by filename (e.g. "*.js")
|
|
63
|
+
- Results show file:line:content format`,
|
|
64
|
+
{
|
|
65
|
+
type: 'object',
|
|
66
|
+
properties: {
|
|
67
|
+
pattern: {
|
|
68
|
+
type: 'string',
|
|
69
|
+
description: 'The regular expression pattern to search for',
|
|
70
|
+
},
|
|
71
|
+
path: {
|
|
72
|
+
type: 'string',
|
|
73
|
+
description: 'The directory to search in (default: cwd)',
|
|
74
|
+
},
|
|
75
|
+
ignore_case: {
|
|
76
|
+
type: 'boolean',
|
|
77
|
+
description: 'Case-insensitive search (default: false)',
|
|
78
|
+
},
|
|
79
|
+
file_pattern: {
|
|
80
|
+
type: 'string',
|
|
81
|
+
description: 'File name pattern filter (e.g. "*.js", "*.{ts,tsx}")',
|
|
82
|
+
},
|
|
83
|
+
context_lines: {
|
|
84
|
+
type: 'number',
|
|
85
|
+
description: 'Number of context lines before/after match',
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
required: ['pattern'],
|
|
89
|
+
},
|
|
90
|
+
async (input, ctx) => {
|
|
91
|
+
const searchPath = input.path
|
|
92
|
+
? (isAbsolute(input.path) ? input.path : resolve(ctx.cwd || process.cwd(), input.path))
|
|
93
|
+
: (ctx.cwd || process.cwd())
|
|
94
|
+
|
|
95
|
+
// 尝试 rg → grep → 纯 JS
|
|
96
|
+
let result = await grepWithRg(input.pattern, searchPath, input)
|
|
97
|
+
if (result === null) {
|
|
98
|
+
result = await grepWithGrep(input.pattern, searchPath, input)
|
|
99
|
+
}
|
|
100
|
+
if (result === null) {
|
|
101
|
+
result = `[Neither ripgrep nor grep found. Install rg for best performance.]`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 截断过长输出
|
|
105
|
+
const MAX_OUTPUT = 50000
|
|
106
|
+
if (result.length > MAX_OUTPUT) {
|
|
107
|
+
result = result.slice(0, MAX_OUTPUT) + `\n\n[... truncated at ${MAX_OUTPUT} chars]`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!result.trim()) {
|
|
111
|
+
return `[No matches for pattern: ${input.pattern} in ${searchPath}]`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return result
|
|
115
|
+
},
|
|
116
|
+
'always-allow'
|
|
117
|
+
)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工具注册表 — 导出所有内置工具
|
|
3
|
+
*/
|
|
4
|
+
import { bashTool } from './bash.js'
|
|
5
|
+
import { fileReadTool } from './file-read.js'
|
|
6
|
+
import { fileEditTool } from './file-edit.js'
|
|
7
|
+
import { fileWriteTool } from './file-write.js'
|
|
8
|
+
import { globTool } from './glob.js'
|
|
9
|
+
import { grepTool } from './grep.js'
|
|
10
|
+
import { webFetchTool } from './web-fetch.js'
|
|
11
|
+
import { webSearchTool } from './web-search.js'
|
|
12
|
+
import { askUserTool } from './ask-user.js'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 所有内置工具列表
|
|
16
|
+
*/
|
|
17
|
+
export const builtinTools = [
|
|
18
|
+
bashTool,
|
|
19
|
+
fileReadTool,
|
|
20
|
+
fileEditTool,
|
|
21
|
+
fileWriteTool,
|
|
22
|
+
globTool,
|
|
23
|
+
grepTool,
|
|
24
|
+
webFetchTool,
|
|
25
|
+
webSearchTool,
|
|
26
|
+
askUserTool,
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 工具注册表 — 管理所有可用工具
|
|
31
|
+
*/
|
|
32
|
+
export class ToolRegistry {
|
|
33
|
+
constructor() {
|
|
34
|
+
this.tools = new Map()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 注册一个工具 */
|
|
38
|
+
register(tool) {
|
|
39
|
+
this.tools.set(tool.name, tool)
|
|
40
|
+
return this
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 批量注册 */
|
|
44
|
+
registerAll(tools) {
|
|
45
|
+
for (const tool of tools) {
|
|
46
|
+
this.register(tool)
|
|
47
|
+
}
|
|
48
|
+
return this
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 获取工具 */
|
|
52
|
+
get(name) {
|
|
53
|
+
return this.tools.get(name)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 获取所有工具 */
|
|
57
|
+
getAll() {
|
|
58
|
+
return Array.from(this.tools.values())
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 获取工具名称列表 */
|
|
62
|
+
getNames() {
|
|
63
|
+
return Array.from(this.tools.keys())
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 注销工具 */
|
|
67
|
+
unregister(name) {
|
|
68
|
+
return this.tools.delete(name)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 检查工具是否存在 */
|
|
72
|
+
has(name) {
|
|
73
|
+
return this.tools.has(name)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 生成 OpenAI function-calling 格式的工具定义 */
|
|
77
|
+
toOpenAITools() {
|
|
78
|
+
return this.getAll().map(t => ({
|
|
79
|
+
type: 'function',
|
|
80
|
+
function: {
|
|
81
|
+
name: t.name,
|
|
82
|
+
description: t.description,
|
|
83
|
+
parameters: t.parameters,
|
|
84
|
+
},
|
|
85
|
+
}))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 生成 tool_use 格式的工具定义(兼容多种 API) */
|
|
89
|
+
getToolDefinitions() {
|
|
90
|
+
return this.getAll().map(t => ({
|
|
91
|
+
name: t.name,
|
|
92
|
+
description: t.description,
|
|
93
|
+
input_schema: t.parameters,
|
|
94
|
+
}))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** @deprecated 使用 getToolDefinitions() 替代 */
|
|
98
|
+
toAnthropicTools() {
|
|
99
|
+
return this.getToolDefinitions()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 创建包含所有内置工具的注册表
|
|
105
|
+
*/
|
|
106
|
+
export function createDefaultRegistry() {
|
|
107
|
+
const registry = new ToolRegistry()
|
|
108
|
+
registry.registerAll(builtinTools)
|
|
109
|
+
return registry
|
|
110
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebFetch 工具 — 抓取网页内容
|
|
3
|
+
* 对应原版: src/tools/WebFetchTool/
|
|
4
|
+
*/
|
|
5
|
+
import { ToolDef } from '../types/index.js'
|
|
6
|
+
import { checkUrlSafety } from '../security/ssrf-guard.js'
|
|
7
|
+
|
|
8
|
+
const MAX_FETCH_CHARS = 100000
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 简单的 HTML → 纯文本转换
|
|
12
|
+
*/
|
|
13
|
+
function htmlToText(html) {
|
|
14
|
+
let text = html
|
|
15
|
+
// 移除 script/style
|
|
16
|
+
text = text.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
17
|
+
text = text.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
18
|
+
text = text.replace(/<nav[\s\S]*?<\/nav>/gi, '')
|
|
19
|
+
// 保留一些有用的标签语义
|
|
20
|
+
text = text.replace(/<h[1-6][^>]*>/gi, '\n## ')
|
|
21
|
+
text = text.replace(/<\/h[1-6]>/gi, '\n')
|
|
22
|
+
text = text.replace(/<p[^>]*>/gi, '\n')
|
|
23
|
+
text = text.replace(/<br\s*\/?>/gi, '\n')
|
|
24
|
+
text = text.replace(/<li[^>]*>/gi, '\n- ')
|
|
25
|
+
text = text.replace(/<a[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, '[$2]($1)')
|
|
26
|
+
text = text.replace(/<img[^>]*alt="([^"]*)"[^>]*>/gi, '[image: $1]')
|
|
27
|
+
// 移除所有剩余 HTML 标签
|
|
28
|
+
text = text.replace(/<[^>]+>/g, '')
|
|
29
|
+
// 解码常见 HTML 实体
|
|
30
|
+
text = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
31
|
+
text = text.replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, ' ')
|
|
32
|
+
// 清理多余空白
|
|
33
|
+
text = text.replace(/\n{3,}/g, '\n\n').trim()
|
|
34
|
+
|
|
35
|
+
return text
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const webFetchTool = new ToolDef(
|
|
39
|
+
'WebFetch',
|
|
40
|
+
`Fetch and extract content from a URL.
|
|
41
|
+
Usage:
|
|
42
|
+
- url must be a valid HTTP/HTTPS URL
|
|
43
|
+
- Returns the page content as cleaned text/markdown
|
|
44
|
+
- Supports HTML pages, plain text, and JSON APIs`,
|
|
45
|
+
{
|
|
46
|
+
type: 'object',
|
|
47
|
+
properties: {
|
|
48
|
+
url: {
|
|
49
|
+
type: 'string',
|
|
50
|
+
description: 'The URL to fetch',
|
|
51
|
+
},
|
|
52
|
+
format: {
|
|
53
|
+
type: 'string',
|
|
54
|
+
enum: ['text', 'json', 'raw'],
|
|
55
|
+
description: 'Output format: text (cleaned HTML), json (parse as JSON), raw (raw response)',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
required: ['url'],
|
|
59
|
+
},
|
|
60
|
+
async (input, ctx) => {
|
|
61
|
+
const { url, format = 'text' } = input
|
|
62
|
+
|
|
63
|
+
// SSRF 安全检查
|
|
64
|
+
const urlSafety = await checkUrlSafety(url)
|
|
65
|
+
if (!urlSafety.allowed) {
|
|
66
|
+
return `[🚫 URL 被安全策略阻止]\n${urlSafety.reason}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const response = await fetch(url, {
|
|
71
|
+
headers: {
|
|
72
|
+
'User-Agent': 'ClaudeCode-Node/1.0',
|
|
73
|
+
'Accept': 'text/html,application/json,text/plain,*/*',
|
|
74
|
+
},
|
|
75
|
+
signal: AbortSignal.timeout(30000),
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
return `[HTTP ${response.status} ${response.statusText}]`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const contentType = response.headers.get('content-type') || ''
|
|
83
|
+
const body = await response.text()
|
|
84
|
+
|
|
85
|
+
// JSON 格式
|
|
86
|
+
if (format === 'json' || contentType.includes('application/json')) {
|
|
87
|
+
try {
|
|
88
|
+
const data = JSON.parse(body)
|
|
89
|
+
const formatted = JSON.stringify(data, null, 2)
|
|
90
|
+
return formatted.length > MAX_FETCH_CHARS
|
|
91
|
+
? formatted.slice(0, MAX_FETCH_CHARS) + '\n[...truncated]'
|
|
92
|
+
: formatted
|
|
93
|
+
} catch {
|
|
94
|
+
return body.slice(0, MAX_FETCH_CHARS)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 原始格式
|
|
99
|
+
if (format === 'raw') {
|
|
100
|
+
return body.length > MAX_FETCH_CHARS
|
|
101
|
+
? body.slice(0, MAX_FETCH_CHARS) + '\n[...truncated]'
|
|
102
|
+
: body
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// HTML → 文本
|
|
106
|
+
if (contentType.includes('text/html')) {
|
|
107
|
+
const text = htmlToText(body)
|
|
108
|
+
return text.length > MAX_FETCH_CHARS
|
|
109
|
+
? text.slice(0, MAX_FETCH_CHARS) + '\n[...truncated]'
|
|
110
|
+
: text
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 纯文本
|
|
114
|
+
return body.length > MAX_FETCH_CHARS
|
|
115
|
+
? body.slice(0, MAX_FETCH_CHARS) + '\n[...truncated]'
|
|
116
|
+
: body
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if (err.name === 'TimeoutError') {
|
|
119
|
+
return `[Error: Request timed out after 30s]`
|
|
120
|
+
}
|
|
121
|
+
return `[Error fetching URL: ${err.message}]`
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
'ask'
|
|
125
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSearch 工具 — 网页搜索(占位符)
|
|
3
|
+
* 对应原版: src/tools/WebSearchTool/
|
|
4
|
+
*/
|
|
5
|
+
import { ToolDef } from '../types/index.js'
|
|
6
|
+
|
|
7
|
+
export const webSearchTool = new ToolDef(
|
|
8
|
+
'WebSearch',
|
|
9
|
+
`Search the web for information.
|
|
10
|
+
NOTE: This tool requires a search API key to be configured.
|
|
11
|
+
Set BRAVE_SEARCH_API_KEY or GOOGLE_SEARCH_API_KEY in environment variables.`,
|
|
12
|
+
{
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {
|
|
15
|
+
query: {
|
|
16
|
+
type: 'string',
|
|
17
|
+
description: 'The search query',
|
|
18
|
+
},
|
|
19
|
+
count: {
|
|
20
|
+
type: 'number',
|
|
21
|
+
description: 'Number of results to return (default: 10)',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
required: ['query'],
|
|
25
|
+
},
|
|
26
|
+
async (input, ctx) => {
|
|
27
|
+
const braveKey = process.env.BRAVE_SEARCH_API_KEY
|
|
28
|
+
const googleKey = process.env.GOOGLE_SEARCH_API_KEY
|
|
29
|
+
const googleCx = process.env.GOOGLE_SEARCH_CX
|
|
30
|
+
|
|
31
|
+
// 尝试 Brave Search
|
|
32
|
+
if (braveKey) {
|
|
33
|
+
try {
|
|
34
|
+
const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(input.query)}&count=${input.count || 10}`
|
|
35
|
+
const response = await fetch(url, {
|
|
36
|
+
headers: { 'X-Subscription-Token': braveKey, 'Accept': 'application/json' },
|
|
37
|
+
signal: AbortSignal.timeout(15000),
|
|
38
|
+
})
|
|
39
|
+
if (response.ok) {
|
|
40
|
+
const data = await response.json()
|
|
41
|
+
const results = (data.web?.results || []).map((r, i) =>
|
|
42
|
+
`${i + 1}. [${r.title}](${r.url})\n ${r.description || ''}`
|
|
43
|
+
).join('\n\n')
|
|
44
|
+
return results || `[No results found for: ${input.query}]`
|
|
45
|
+
}
|
|
46
|
+
} catch (err) {
|
|
47
|
+
return `[Brave Search error: ${err.message}]`
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 尝试 Google Custom Search
|
|
52
|
+
if (googleKey && googleCx) {
|
|
53
|
+
try {
|
|
54
|
+
const url = `https://www.googleapis.com/customsearch/v1?key=${googleKey}&cx=${googleCx}&q=${encodeURIComponent(input.query)}&num=${input.count || 10}`
|
|
55
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(15000) })
|
|
56
|
+
if (response.ok) {
|
|
57
|
+
const data = await response.json()
|
|
58
|
+
const results = (data.items || []).map((r, i) =>
|
|
59
|
+
`${i + 1}. [${r.title}](${r.link})\n ${r.snippet || ''}`
|
|
60
|
+
).join('\n\n')
|
|
61
|
+
return results || `[No results found for: ${input.query}]`
|
|
62
|
+
}
|
|
63
|
+
} catch (err) {
|
|
64
|
+
return `[Google Search error: ${err.message}]`
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return `[WebSearch requires an API key. Set one of:
|
|
69
|
+
- BRAVE_SEARCH_API_KEY (Brave Search API)
|
|
70
|
+
- GOOGLE_SEARCH_API_KEY + GOOGLE_SEARCH_CX (Google Custom Search)
|
|
71
|
+
|
|
72
|
+
Query was: "${input.query}"]`
|
|
73
|
+
},
|
|
74
|
+
'ask'
|
|
75
|
+
)
|