@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.
@@ -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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
31
+ text = text.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/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
+ )