cowork-cli 1.1.0 → 2.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 +1 -0
- package/package.json +2 -2
- package/src/engine/models/BaseModel.js +6 -1
- package/src/engine/tools/findDir.js +18 -10
- package/src/engine/tools/findFile.js +17 -10
- package/src/engine/tools/index.js +20 -2
- package/src/engine/tools/listTools.js +15 -3
- package/src/engine/tools/projectTree.js +15 -8
- package/src/engine/tools/readDir.js +11 -4
- package/src/engine/tools/readFile.js +13 -5
- package/src/engine/tools/readFileChunk.js +13 -5
- package/src/engine/tools/searchText.js +71 -24
- package/src/engine/tools/webSearch.js +78 -0
package/README.md
CHANGED
|
@@ -56,6 +56,7 @@ cwk "Explain the data flow in the engine/ models"
|
|
|
56
56
|
- **Zero-Whitespace UI**: High-density terminal output designed for professionals. No fluff, no headers, just data.
|
|
57
57
|
- **Interactive Feedback**: The AI can request clarifications via the `askUser` tool or trigger an interactive `[ Yes ] No` toggle using `askConfirm`.
|
|
58
58
|
- **Smart Discovery**: Built-in `searchText`, `findFile`, and `projectTree` tools that respect your `.gitignore`.
|
|
59
|
+
- **Web Research**: Dynamically search the web (`webSearch`) and read documentation (`webFetch`) directly from the CLI.
|
|
59
60
|
- **Surgical I/O**: Read entire files or specific line ranges (`readFileChunk`) with automatic binary detection.
|
|
60
61
|
- **Piping Support**: Pipe logs or diffs directly into `cwk` for instant analysis.
|
|
61
62
|
|
package/package.json
CHANGED
|
@@ -181,6 +181,7 @@ export default class BaseModel {
|
|
|
181
181
|
readFileChunk: 'peeking',
|
|
182
182
|
searchText: 'searching',
|
|
183
183
|
webFetch: 'fetching',
|
|
184
|
+
webSearch: 'searching web',
|
|
184
185
|
findFile: 'finding',
|
|
185
186
|
findDir: 'finding',
|
|
186
187
|
listTools: 'listing'
|
|
@@ -189,7 +190,11 @@ export default class BaseModel {
|
|
|
189
190
|
const label = toolLabels[name] || name;
|
|
190
191
|
let displayArg = "";
|
|
191
192
|
|
|
192
|
-
if (name === 'searchText')
|
|
193
|
+
if (name === 'searchText') {
|
|
194
|
+
const ctx = (args.context != null && args.context !== 2) ? ` [C${args.context}]` : '';
|
|
195
|
+
displayArg = `'${args.pattern}' in ${args.path}${ctx}`;
|
|
196
|
+
}
|
|
197
|
+
else if (name === 'webSearch') displayArg = `'${args.query}'`;
|
|
193
198
|
else if (name === 'findFile' || name === 'findDir') displayArg = `'${args.pattern}' in ${args.dirPath || '.'}`;
|
|
194
199
|
else if (name === 'readFileChunk') displayArg = `${args.filePath} [L${args.startLine}-${args.endLine}]`;
|
|
195
200
|
else displayArg = args.url || args.filePath || args.dirPath || args.path || args.pattern || JSON.stringify(args);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import { getIgnorePatterns,
|
|
3
|
+
import { getIgnorePatterns, isSafeEntry, loadNestedIgnores, safePath } from '../../utils/fsUtils.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* findDir tool: Finds directories by name using regex.
|
|
@@ -13,10 +13,10 @@ import { getIgnorePatterns, shouldIgnore } from '../../utils/fsUtils.js';
|
|
|
13
13
|
export default async function findDir({ pattern, dirPath = '.', recursive = true, limit = 15 }) {
|
|
14
14
|
try {
|
|
15
15
|
if (!pattern) return "Error: Search pattern cannot be empty.";
|
|
16
|
-
|
|
16
|
+
|
|
17
17
|
// Enforce max limit of 15
|
|
18
18
|
const finalLimit = Math.min(limit, 15);
|
|
19
|
-
|
|
19
|
+
|
|
20
20
|
let regex;
|
|
21
21
|
try {
|
|
22
22
|
regex = new RegExp(pattern, 'i');
|
|
@@ -24,10 +24,17 @@ export default async function findDir({ pattern, dirPath = '.', recursive = true
|
|
|
24
24
|
return `Error: Invalid regex pattern '${pattern}': ${e.message}`;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
let resolvedPath;
|
|
28
|
+
try {
|
|
29
|
+
resolvedPath = safePath(dirPath);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
return `Error: ${err.message}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const rootIgnoreList = await getIgnorePatterns();
|
|
28
35
|
const results = [];
|
|
29
36
|
|
|
30
|
-
async function walk(currentPath) {
|
|
37
|
+
async function walk(currentPath, ignoreList) {
|
|
31
38
|
if (results.length >= finalLimit) return;
|
|
32
39
|
|
|
33
40
|
let items;
|
|
@@ -39,23 +46,24 @@ export default async function findDir({ pattern, dirPath = '.', recursive = true
|
|
|
39
46
|
|
|
40
47
|
for (const item of items) {
|
|
41
48
|
if (results.length >= finalLimit) break;
|
|
42
|
-
if (
|
|
49
|
+
if (!isSafeEntry(item, currentPath, ignoreList)) continue;
|
|
43
50
|
|
|
44
51
|
const fullPath = path.join(currentPath, item.name);
|
|
45
|
-
|
|
52
|
+
|
|
46
53
|
if (item.isDirectory()) {
|
|
47
54
|
if (regex.test(item.name)) {
|
|
48
55
|
results.push(path.relative(process.cwd(), fullPath));
|
|
49
56
|
}
|
|
50
|
-
|
|
57
|
+
|
|
51
58
|
if (recursive) {
|
|
52
|
-
await
|
|
59
|
+
const childIgnores = await loadNestedIgnores(fullPath, ignoreList);
|
|
60
|
+
await walk(fullPath, childIgnores);
|
|
53
61
|
}
|
|
54
62
|
}
|
|
55
63
|
}
|
|
56
64
|
}
|
|
57
65
|
|
|
58
|
-
await walk(
|
|
66
|
+
await walk(resolvedPath, rootIgnoreList);
|
|
59
67
|
|
|
60
68
|
if (results.length === 0) {
|
|
61
69
|
return `No directories found matching "${pattern}" in "${dirPath}".`;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import { getIgnorePatterns,
|
|
3
|
+
import { getIgnorePatterns, isSafeEntry, loadNestedIgnores, safePath } from '../../utils/fsUtils.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* findFile tool: Finds files by name using regex.
|
|
@@ -13,10 +13,10 @@ import { getIgnorePatterns, shouldIgnore } from '../../utils/fsUtils.js';
|
|
|
13
13
|
export default async function findFile({ pattern, dirPath = '.', recursive = true, limit = 15 }) {
|
|
14
14
|
try {
|
|
15
15
|
if (!pattern) return "Error: Search pattern cannot be empty.";
|
|
16
|
-
|
|
16
|
+
|
|
17
17
|
// Enforce max limit of 15
|
|
18
18
|
const finalLimit = Math.min(limit, 15);
|
|
19
|
-
|
|
19
|
+
|
|
20
20
|
let regex;
|
|
21
21
|
try {
|
|
22
22
|
regex = new RegExp(pattern, 'i');
|
|
@@ -24,11 +24,17 @@ export default async function findFile({ pattern, dirPath = '.', recursive = tru
|
|
|
24
24
|
return `Error: Invalid regex pattern '${pattern}': ${e.message}`;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
let resolvedPath;
|
|
28
|
+
try {
|
|
29
|
+
resolvedPath = safePath(dirPath);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
return `Error: ${err.message}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const rootIgnoreList = await getIgnorePatterns();
|
|
28
35
|
const results = [];
|
|
29
|
-
let totalFound = 0;
|
|
30
36
|
|
|
31
|
-
async function walk(currentPath) {
|
|
37
|
+
async function walk(currentPath, ignoreList) {
|
|
32
38
|
if (results.length >= finalLimit) return;
|
|
33
39
|
|
|
34
40
|
let items;
|
|
@@ -40,13 +46,14 @@ export default async function findFile({ pattern, dirPath = '.', recursive = tru
|
|
|
40
46
|
|
|
41
47
|
for (const item of items) {
|
|
42
48
|
if (results.length >= finalLimit) break;
|
|
43
|
-
if (
|
|
49
|
+
if (!isSafeEntry(item, currentPath, ignoreList)) continue;
|
|
44
50
|
|
|
45
51
|
const fullPath = path.join(currentPath, item.name);
|
|
46
|
-
|
|
52
|
+
|
|
47
53
|
if (item.isDirectory()) {
|
|
48
54
|
if (recursive) {
|
|
49
|
-
await
|
|
55
|
+
const childIgnores = await loadNestedIgnores(fullPath, ignoreList);
|
|
56
|
+
await walk(fullPath, childIgnores);
|
|
50
57
|
}
|
|
51
58
|
} else if (item.isFile()) {
|
|
52
59
|
if (regex.test(item.name)) {
|
|
@@ -56,7 +63,7 @@ export default async function findFile({ pattern, dirPath = '.', recursive = tru
|
|
|
56
63
|
}
|
|
57
64
|
}
|
|
58
65
|
|
|
59
|
-
await walk(
|
|
66
|
+
await walk(resolvedPath, rootIgnoreList);
|
|
60
67
|
|
|
61
68
|
if (results.length === 0) {
|
|
62
69
|
return `No files found matching "${pattern}" in "${dirPath}".`;
|
|
@@ -4,6 +4,7 @@ import projectTree from './projectTree.js';
|
|
|
4
4
|
import readFileChunk from './readFileChunk.js';
|
|
5
5
|
import searchText from './searchText.js';
|
|
6
6
|
import webFetch from './webFetch.js';
|
|
7
|
+
import webSearch from './webSearch.js';
|
|
7
8
|
import listTools from './listTools.js';
|
|
8
9
|
import findFile from './findFile.js';
|
|
9
10
|
import findDir from './findDir.js';
|
|
@@ -73,13 +74,14 @@ export const toolDefinitions = [
|
|
|
73
74
|
type: "function",
|
|
74
75
|
function: {
|
|
75
76
|
name: "searchText",
|
|
76
|
-
description: "Regex search in files/folders. Supports recursion and .gitignore.",
|
|
77
|
+
description: "Regex search in files/folders. Returns matching lines with surrounding context lines. Supports recursion and .gitignore.",
|
|
77
78
|
parameters: {
|
|
78
79
|
type: "object",
|
|
79
80
|
properties: {
|
|
80
81
|
pattern: { type: "string", description: "Regex or text pattern." },
|
|
81
82
|
path: { type: "string", description: "File or directory to search." },
|
|
82
|
-
recursive: { type: "boolean", description: "Search subdirectories? (default: false)" }
|
|
83
|
+
recursive: { type: "boolean", description: "Search subdirectories? (default: false)" },
|
|
84
|
+
context: { type: "number", description: "Lines of context around each match (default: 2, max: 5)." }
|
|
83
85
|
},
|
|
84
86
|
required: ["pattern", "path"]
|
|
85
87
|
}
|
|
@@ -99,6 +101,21 @@ export const toolDefinitions = [
|
|
|
99
101
|
}
|
|
100
102
|
}
|
|
101
103
|
},
|
|
104
|
+
{
|
|
105
|
+
type: "function",
|
|
106
|
+
function: {
|
|
107
|
+
name: "webSearch",
|
|
108
|
+
description: "Search the web to find URLs, docs, and solutions. Returns a list of titles, URLs, and snippet summaries. Always use this first to find a URL before calling webFetch.",
|
|
109
|
+
parameters: {
|
|
110
|
+
type: "object",
|
|
111
|
+
properties: {
|
|
112
|
+
query: { type: "string", description: "The search term/query." },
|
|
113
|
+
limit: { type: "number", description: "Max results to return (default: 5, max: 20)." }
|
|
114
|
+
},
|
|
115
|
+
required: ["query"]
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
},
|
|
102
119
|
{
|
|
103
120
|
type: "function",
|
|
104
121
|
function: {
|
|
@@ -182,6 +199,7 @@ const toolImplementations = {
|
|
|
182
199
|
readFileChunk,
|
|
183
200
|
searchText,
|
|
184
201
|
webFetch,
|
|
202
|
+
webSearch,
|
|
185
203
|
listTools,
|
|
186
204
|
findFile,
|
|
187
205
|
findDir,
|
|
@@ -30,9 +30,9 @@ export default async function listTools() {
|
|
|
30
30
|
},
|
|
31
31
|
{
|
|
32
32
|
name: "searchText",
|
|
33
|
-
usage: "searchText({ pattern: '
|
|
34
|
-
description: "Performs a regex search for text across files and directories. Respects .gitignore.",
|
|
35
|
-
whenToUse: "To find variable usages,
|
|
33
|
+
usage: "searchText({ pattern: 'safePath', path: 'src/', recursive: true, context: 3 })",
|
|
34
|
+
description: "Performs a regex search for text across files and directories. Returns each match with surrounding context lines (default: 2, max: 5). Overlapping context windows are merged. Respects .gitignore.",
|
|
35
|
+
whenToUse: "To find variable usages, trace function calls, or locate patterns across the codebase. Increase context when you need more surrounding code per match."
|
|
36
36
|
},
|
|
37
37
|
{
|
|
38
38
|
name: "webFetch",
|
|
@@ -40,6 +40,12 @@ export default async function listTools() {
|
|
|
40
40
|
description: "Fetches and extracts clean text from a web URL, removing HTML clutter.",
|
|
41
41
|
whenToUse: "To gather information from online documentation, API references, or external technical articles."
|
|
42
42
|
},
|
|
43
|
+
{
|
|
44
|
+
name: "webSearch",
|
|
45
|
+
usage: "webSearch({ query: 'nodejs fetch api example', limit: 5 })",
|
|
46
|
+
description: "Searches the web dynamically and returns a list of titles, direct URLs, and snippet summaries (default: 5, max: 20).",
|
|
47
|
+
whenToUse: "When you need to search the web for external documentation, solutions, or API references before using webFetch to read a specific page."
|
|
48
|
+
},
|
|
43
49
|
{
|
|
44
50
|
name: "findFile",
|
|
45
51
|
usage: "findFile({ pattern: 'config.*\\\\.js$', dirPath: 'src/', limit: 10 })",
|
|
@@ -63,6 +69,12 @@ export default async function listTools() {
|
|
|
63
69
|
usage: "askUser({ question: 'What is the API endpoint for this service?' })",
|
|
64
70
|
description: "Asks the user a specific question via the terminal and waits for a text response.",
|
|
65
71
|
whenToUse: "When you need specific information, clarification, or feedback from the user that cannot be found in the codebase."
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: "askConfirm",
|
|
75
|
+
usage: "askConfirm({ question: 'Should I proceed with deleting this file?' })",
|
|
76
|
+
description: "Asks the user a yes/no question using an interactive toggle. Returns { confirmed: true } for yes, { confirmed: false } for no, or { confirmed: false, dismissed: true } on cancellation.",
|
|
77
|
+
whenToUse: "When only a boolean decision is needed from the user. Prefer this over askUser for simple yes/no choices."
|
|
66
78
|
}
|
|
67
79
|
];
|
|
68
80
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import { getIgnorePatterns,
|
|
3
|
+
import { getIgnorePatterns, isSafeEntry, loadNestedIgnores, safePath } from '../../utils/fsUtils.js';
|
|
4
4
|
|
|
5
5
|
const MAX_DEPTH = 10;
|
|
6
6
|
const MAX_ITEMS = 500;
|
|
@@ -15,17 +15,23 @@ export default async function projectTree({ dirPath }) {
|
|
|
15
15
|
let itemCount = 0;
|
|
16
16
|
let isTruncated = false;
|
|
17
17
|
|
|
18
|
+
let absolutePath;
|
|
19
|
+
try {
|
|
20
|
+
absolutePath = safePath(dirPath);
|
|
21
|
+
} catch (err) {
|
|
22
|
+
return `Error: ${err.message}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
try {
|
|
19
|
-
const absolutePath = path.resolve(dirPath);
|
|
20
26
|
const stats = await fs.stat(absolutePath);
|
|
21
|
-
|
|
27
|
+
|
|
22
28
|
if (!stats.isDirectory()) {
|
|
23
29
|
return `Error: '${dirPath}' is not a directory.`;
|
|
24
30
|
}
|
|
25
31
|
|
|
26
|
-
const
|
|
32
|
+
const rootIgnoreList = await getIgnorePatterns();
|
|
27
33
|
|
|
28
|
-
async function buildTree(currentDir, depth = 0, currentPrefix = '') {
|
|
34
|
+
async function buildTree(currentDir, depth = 0, currentPrefix = '', ignoreList = rootIgnoreList) {
|
|
29
35
|
if (depth > MAX_DEPTH || itemCount >= MAX_ITEMS) {
|
|
30
36
|
if (itemCount >= MAX_ITEMS) isTruncated = true;
|
|
31
37
|
return '';
|
|
@@ -40,7 +46,7 @@ export default async function projectTree({ dirPath }) {
|
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
const filteredItems = items
|
|
43
|
-
.filter(item =>
|
|
49
|
+
.filter(item => isSafeEntry(item, currentDir, ignoreList))
|
|
44
50
|
.sort((a, b) => {
|
|
45
51
|
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
46
52
|
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
@@ -64,7 +70,8 @@ export default async function projectTree({ dirPath }) {
|
|
|
64
70
|
|
|
65
71
|
if (item.isDirectory()) {
|
|
66
72
|
const fullPath = path.join(currentDir, item.name);
|
|
67
|
-
|
|
73
|
+
const childIgnores = await loadNestedIgnores(fullPath, ignoreList);
|
|
74
|
+
result += await buildTree(fullPath, depth + 1, currentPrefix + childPrefix, childIgnores);
|
|
68
75
|
}
|
|
69
76
|
}
|
|
70
77
|
return result;
|
|
@@ -72,7 +79,7 @@ export default async function projectTree({ dirPath }) {
|
|
|
72
79
|
|
|
73
80
|
const rootName = path.basename(absolutePath) || absolutePath;
|
|
74
81
|
const tree = await buildTree(absolutePath);
|
|
75
|
-
|
|
82
|
+
|
|
76
83
|
let finalOutput = `${rootName}/\n${tree || '└(empty)'}`;
|
|
77
84
|
finalOutput = finalOutput.trimEnd();
|
|
78
85
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
|
-
import { getIgnorePatterns,
|
|
2
|
+
import { getIgnorePatterns, isSafeEntry, safePath } from '../../utils/fsUtils.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Implementation of the readDir tool.
|
|
@@ -8,12 +8,19 @@ import { getIgnorePatterns, shouldIgnore } from '../../utils/fsUtils.js';
|
|
|
8
8
|
* @returns {Promise<string>} List of files and folders or error message.
|
|
9
9
|
*/
|
|
10
10
|
export default async function readDir({ dirPath }) {
|
|
11
|
+
let resolvedPath;
|
|
12
|
+
try {
|
|
13
|
+
resolvedPath = safePath(dirPath);
|
|
14
|
+
} catch (err) {
|
|
15
|
+
return `Error: ${err.message}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
try {
|
|
12
19
|
const ignoreList = await getIgnorePatterns();
|
|
13
|
-
const items = await fs.readdir(
|
|
14
|
-
|
|
20
|
+
const items = await fs.readdir(resolvedPath, { withFileTypes: true });
|
|
21
|
+
|
|
15
22
|
const formattedItems = items
|
|
16
|
-
.filter(item =>
|
|
23
|
+
.filter(item => isSafeEntry(item, resolvedPath, ignoreList))
|
|
17
24
|
.map(item => {
|
|
18
25
|
const type = item.isDirectory() ? '[D]' : '[F]';
|
|
19
26
|
return `${type}${item.name}`;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import { Buffer } from 'buffer';
|
|
3
|
+
import { safePath } from '../../utils/fsUtils.js';
|
|
3
4
|
|
|
4
5
|
const MAX_FILE_SIZE = 1024 * 1024; // 1MB limit
|
|
5
6
|
|
|
@@ -10,9 +11,16 @@ const MAX_FILE_SIZE = 1024 * 1024; // 1MB limit
|
|
|
10
11
|
* @returns {Promise<string>} File content or error message.
|
|
11
12
|
*/
|
|
12
13
|
export default async function readFile({ filePath }) {
|
|
14
|
+
let resolvedPath;
|
|
13
15
|
try {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
resolvedPath = safePath(filePath);
|
|
17
|
+
} catch (err) {
|
|
18
|
+
return `Error: ${err.message}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const stats = await fs.stat(resolvedPath);
|
|
23
|
+
|
|
16
24
|
if (!stats.isFile()) {
|
|
17
25
|
return `Error: '${filePath}' is not a file.`;
|
|
18
26
|
}
|
|
@@ -22,17 +30,17 @@ export default async function readFile({ filePath }) {
|
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
// Binary check: read first 1KB and look for null bytes
|
|
25
|
-
const handle = await fs.open(
|
|
33
|
+
const handle = await fs.open(resolvedPath, 'r');
|
|
26
34
|
const { bytesRead, buffer } = await handle.read(Buffer.alloc(1024), 0, 1024, 0);
|
|
27
35
|
await handle.close();
|
|
28
|
-
|
|
36
|
+
|
|
29
37
|
for (let i = 0; i < bytesRead; i++) {
|
|
30
38
|
if (buffer[i] === 0) {
|
|
31
39
|
return `Error: '${filePath}' appears to be a binary file. Reading binary files is not supported.`;
|
|
32
40
|
}
|
|
33
41
|
}
|
|
34
42
|
|
|
35
|
-
const content = await fs.readFile(
|
|
43
|
+
const content = await fs.readFile(resolvedPath, 'utf8');
|
|
36
44
|
return content;
|
|
37
45
|
} catch (err) {
|
|
38
46
|
if (err.code === 'ENOENT') return `Error: File not found at '${filePath}'.`;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import { Buffer } from 'buffer';
|
|
3
|
+
import { safePath } from '../../utils/fsUtils.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Implementation of the readFileChunk tool.
|
|
@@ -10,22 +11,29 @@ import { Buffer } from 'buffer';
|
|
|
10
11
|
* @returns {Promise<string>} File chunk or error message.
|
|
11
12
|
*/
|
|
12
13
|
export default async function readFileChunk({ filePath, startLine, endLine }) {
|
|
14
|
+
let resolvedPath;
|
|
13
15
|
try {
|
|
14
|
-
|
|
16
|
+
resolvedPath = safePath(filePath);
|
|
17
|
+
} catch (err) {
|
|
18
|
+
return `Error: ${err.message}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const stats = await fs.stat(resolvedPath);
|
|
15
23
|
if (!stats.isFile()) return `Error: '${filePath}' is not a file.`;
|
|
16
24
|
|
|
17
25
|
// Binary check: read first 1KB and look for null bytes
|
|
18
|
-
const handle = await fs.open(
|
|
26
|
+
const handle = await fs.open(resolvedPath, 'r');
|
|
19
27
|
const { bytesRead, buffer } = await handle.read(Buffer.alloc(1024), 0, 1024, 0);
|
|
20
28
|
await handle.close();
|
|
21
|
-
|
|
29
|
+
|
|
22
30
|
for (let i = 0; i < bytesRead; i++) {
|
|
23
31
|
if (buffer[i] === 0) return `Error: '${filePath}' appears to be a binary file. Reading binary files is not supported.`;
|
|
24
32
|
}
|
|
25
33
|
|
|
26
|
-
const data = await fs.readFile(
|
|
34
|
+
const data = await fs.readFile(resolvedPath, 'utf8');
|
|
27
35
|
const lines = data.split('\n');
|
|
28
|
-
|
|
36
|
+
|
|
29
37
|
// Boundary validation
|
|
30
38
|
const totalLines = lines.length;
|
|
31
39
|
const actualStart = Math.max(1, startLine);
|
|
@@ -1,21 +1,29 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { Buffer } from 'buffer';
|
|
4
|
-
import { getIgnorePatterns,
|
|
4
|
+
import { getIgnorePatterns, isSafeEntry, loadNestedIgnores, safePath } from '../../utils/fsUtils.js';
|
|
5
5
|
|
|
6
6
|
const MAX_MATCHES_PER_FILE = 20;
|
|
7
7
|
const MAX_TOTAL_MATCHES = 100;
|
|
8
8
|
const MAX_DEPTH = 10;
|
|
9
|
+
const CONTEXT_LINES = 2;
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Enhanced searchText tool with recursion, ignore rules, and safety limits.
|
|
12
13
|
*/
|
|
13
|
-
export default async function searchText({ pattern, path: searchPath, recursive = false }) {
|
|
14
|
+
export default async function searchText({ pattern, path: searchPath, recursive = false, context = CONTEXT_LINES }) {
|
|
14
15
|
let totalMatches = 0;
|
|
15
16
|
let isTruncated = false;
|
|
16
17
|
|
|
18
|
+
let resolvedPath;
|
|
17
19
|
try {
|
|
18
|
-
|
|
20
|
+
resolvedPath = safePath(searchPath);
|
|
21
|
+
} catch (err) {
|
|
22
|
+
return `Error: ${err.message}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const stats = await fs.stat(resolvedPath);
|
|
19
27
|
if (!pattern) return "Error: Search pattern cannot be empty.";
|
|
20
28
|
|
|
21
29
|
let regex;
|
|
@@ -25,15 +33,15 @@ export default async function searchText({ pattern, path: searchPath, recursive
|
|
|
25
33
|
return `Error: Invalid regex pattern '${pattern}': ${e.message}`;
|
|
26
34
|
}
|
|
27
35
|
|
|
28
|
-
const
|
|
36
|
+
const rootIgnoreList = await getIgnorePatterns();
|
|
29
37
|
const results = [];
|
|
30
38
|
|
|
31
|
-
const walk = async (currentPath, depth = 0) => {
|
|
39
|
+
const walk = async (currentPath, depth = 0, ignoreList = rootIgnoreList) => {
|
|
32
40
|
if (totalMatches >= MAX_TOTAL_MATCHES) {
|
|
33
41
|
isTruncated = true;
|
|
34
42
|
return;
|
|
35
43
|
}
|
|
36
|
-
|
|
44
|
+
|
|
37
45
|
if (depth > MAX_DEPTH) return;
|
|
38
46
|
|
|
39
47
|
let items;
|
|
@@ -49,21 +57,23 @@ export default async function searchText({ pattern, path: searchPath, recursive
|
|
|
49
57
|
break;
|
|
50
58
|
}
|
|
51
59
|
|
|
52
|
-
if (
|
|
60
|
+
if (!isSafeEntry(item, currentPath, ignoreList)) continue;
|
|
53
61
|
|
|
54
62
|
const fullPath = path.join(currentPath, item.name);
|
|
55
63
|
|
|
56
64
|
if (item.isDirectory()) {
|
|
57
65
|
if (recursive || depth === 0) {
|
|
58
|
-
await
|
|
66
|
+
const childIgnores = await loadNestedIgnores(fullPath, ignoreList);
|
|
67
|
+
await walk(fullPath, depth + 1, childIgnores);
|
|
59
68
|
}
|
|
60
69
|
} else if (item.isFile()) {
|
|
61
|
-
const
|
|
70
|
+
const allowed = MAX_TOTAL_MATCHES - totalMatches;
|
|
71
|
+
const fileMatches = await searchInFile(fullPath, regex, context);
|
|
62
72
|
if (fileMatches.length > 0) {
|
|
63
|
-
const allowedInFile = Math.min(fileMatches.length,
|
|
73
|
+
const allowedInFile = Math.min(fileMatches.length, allowed);
|
|
64
74
|
results.push({
|
|
65
75
|
file: path.relative(process.cwd(), fullPath),
|
|
66
|
-
|
|
76
|
+
blocks: fileMatches.slice(0, allowedInFile)
|
|
67
77
|
});
|
|
68
78
|
totalMatches += allowedInFile;
|
|
69
79
|
if (fileMatches.length > allowedInFile) isTruncated = true;
|
|
@@ -73,24 +83,24 @@ export default async function searchText({ pattern, path: searchPath, recursive
|
|
|
73
83
|
};
|
|
74
84
|
|
|
75
85
|
if (stats.isFile()) {
|
|
76
|
-
const fileMatches = await searchInFile(
|
|
86
|
+
const fileMatches = await searchInFile(resolvedPath, regex, context);
|
|
77
87
|
if (fileMatches.length > 0) {
|
|
78
88
|
const allowed = Math.min(fileMatches.length, MAX_TOTAL_MATCHES);
|
|
79
89
|
results.push({
|
|
80
|
-
file: path.relative(process.cwd(),
|
|
81
|
-
|
|
90
|
+
file: path.relative(process.cwd(), resolvedPath),
|
|
91
|
+
blocks: fileMatches.slice(0, allowed)
|
|
82
92
|
});
|
|
83
93
|
totalMatches = allowed;
|
|
84
94
|
if (fileMatches.length > allowed) isTruncated = true;
|
|
85
95
|
}
|
|
86
96
|
} else {
|
|
87
|
-
await walk(
|
|
97
|
+
await walk(resolvedPath);
|
|
88
98
|
}
|
|
89
99
|
|
|
90
100
|
if (results.length === 0) return "No matches found.";
|
|
91
101
|
|
|
92
102
|
let output = results.map(res => {
|
|
93
|
-
return `[${res.file}]\n${res.
|
|
103
|
+
return `[${res.file}]\n${res.blocks.join('\n---\n')}`;
|
|
94
104
|
}).join('\n');
|
|
95
105
|
|
|
96
106
|
if (isTruncated) {
|
|
@@ -99,35 +109,72 @@ export default async function searchText({ pattern, path: searchPath, recursive
|
|
|
99
109
|
|
|
100
110
|
return output;
|
|
101
111
|
|
|
102
|
-
|
|
103
112
|
} catch (err) {
|
|
104
113
|
if (err.code === 'ENOENT') return `Error: Path not found at '${searchPath}'.`;
|
|
105
114
|
return `Error searching text: ${err.message}`;
|
|
106
115
|
}
|
|
107
116
|
}
|
|
108
117
|
|
|
109
|
-
async function searchInFile(filePath, regex) {
|
|
118
|
+
async function searchInFile(filePath, regex, contextLines = CONTEXT_LINES) {
|
|
119
|
+
const CTX = Math.min(Math.max(0, contextLines), 5);
|
|
120
|
+
|
|
110
121
|
try {
|
|
122
|
+
// Binary check — read first 1KB
|
|
111
123
|
const handle = await fs.open(filePath, 'r');
|
|
112
124
|
const { bytesRead, buffer } = await handle.read(Buffer.alloc(1024), 0, 1024, 0);
|
|
113
125
|
await handle.close();
|
|
114
|
-
|
|
126
|
+
|
|
115
127
|
for (let i = 0; i < bytesRead; i++) {
|
|
116
128
|
if (buffer[i] === 0) return []; // Skip binary
|
|
117
129
|
}
|
|
118
130
|
|
|
119
131
|
const content = await fs.readFile(filePath, 'utf8');
|
|
120
132
|
const lines = content.split('\n');
|
|
121
|
-
|
|
122
|
-
|
|
133
|
+
|
|
134
|
+
// Collect all matching line indices (0-based)
|
|
135
|
+
const matchIndices = [];
|
|
123
136
|
for (let i = 0; i < lines.length; i++) {
|
|
124
137
|
if (regex.test(lines[i])) {
|
|
125
|
-
|
|
126
|
-
if (
|
|
138
|
+
matchIndices.push(i);
|
|
139
|
+
if (matchIndices.length >= MAX_MATCHES_PER_FILE * 3) break; // Internal safety cap
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (matchIndices.length === 0) return [];
|
|
144
|
+
|
|
145
|
+
// Build context windows, merging overlapping ones
|
|
146
|
+
const blocks = [];
|
|
147
|
+
let currentBlock = null;
|
|
148
|
+
|
|
149
|
+
for (const idx of matchIndices) {
|
|
150
|
+
const start = Math.max(0, idx - CTX);
|
|
151
|
+
const end = Math.min(lines.length - 1, idx + CTX);
|
|
152
|
+
|
|
153
|
+
if (currentBlock && start <= currentBlock.end + 1) {
|
|
154
|
+
// Overlapping or adjacent — merge into current block
|
|
155
|
+
currentBlock.end = Math.max(currentBlock.end, end);
|
|
156
|
+
currentBlock.matchIndices.add(idx);
|
|
157
|
+
} else {
|
|
158
|
+
if (currentBlock) blocks.push(formatBlock(lines, currentBlock));
|
|
159
|
+
if (blocks.length >= MAX_MATCHES_PER_FILE) break;
|
|
160
|
+
currentBlock = { start, end, matchIndices: new Set([idx]) };
|
|
127
161
|
}
|
|
128
162
|
}
|
|
129
|
-
|
|
163
|
+
if (currentBlock && blocks.length < MAX_MATCHES_PER_FILE) {
|
|
164
|
+
blocks.push(formatBlock(lines, currentBlock));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return blocks;
|
|
130
168
|
} catch (err) {
|
|
131
169
|
return [];
|
|
132
170
|
}
|
|
133
171
|
}
|
|
172
|
+
|
|
173
|
+
function formatBlock(lines, block) {
|
|
174
|
+
let output = "";
|
|
175
|
+
for (let i = block.start; i <= block.end; i++) {
|
|
176
|
+
const prefix = block.matchIndices.has(i) ? "► " : " ";
|
|
177
|
+
output += `${i + 1}:${prefix}${lines[i]}\n`;
|
|
178
|
+
}
|
|
179
|
+
return output.trim();
|
|
180
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const TIMEOUT_MS = 10000;
|
|
2
|
+
const MAX_RESULTS_HARD_LIMIT = 20;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Searches the web using DuckDuckGo HTML (zero dependencies).
|
|
6
|
+
* Extracts title, clean URL, and snippet summary.
|
|
7
|
+
*
|
|
8
|
+
* @param {Object} args
|
|
9
|
+
* @param {string} args.query - The search query.
|
|
10
|
+
* @param {number} [args.limit=5] - Max results to return (max 20).
|
|
11
|
+
* @returns {Promise<string>} JSON string of search results or error message.
|
|
12
|
+
*/
|
|
13
|
+
export default async function webSearch({ query, limit = 5 }) {
|
|
14
|
+
if (!query) return "Error: Search query cannot be empty.";
|
|
15
|
+
|
|
16
|
+
const maxLimit = Math.min(Math.max(1, limit), MAX_RESULTS_HARD_LIMIT);
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const controller = new AbortController();
|
|
20
|
+
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
21
|
+
|
|
22
|
+
const response = await fetch('https://html.duckduckgo.com/html/', {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
signal: controller.signal,
|
|
25
|
+
headers: {
|
|
26
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
27
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
28
|
+
},
|
|
29
|
+
body: `q=${encodeURIComponent(query)}`
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
clearTimeout(timeoutId);
|
|
33
|
+
|
|
34
|
+
if (!response.ok) {
|
|
35
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const html = await response.text();
|
|
39
|
+
const results = [];
|
|
40
|
+
|
|
41
|
+
// Split HTML into result blocks.
|
|
42
|
+
// The slice(1) skips the header block before the first result.
|
|
43
|
+
const blocks = html.split('class="links_main links_deep result__body"').slice(1);
|
|
44
|
+
|
|
45
|
+
for (const block of blocks) {
|
|
46
|
+
if (results.length >= maxLimit) break;
|
|
47
|
+
|
|
48
|
+
const titleMatch = block.match(/<h2 class="result__title">[\s\S]*?<a[^>]*>([\s\S]*?)<\/a>/i);
|
|
49
|
+
const snippetMatch = block.match(/<a class="result__snippet[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
|
|
50
|
+
|
|
51
|
+
if (titleMatch && snippetMatch) {
|
|
52
|
+
// Strip nested HTML tags (like <b> tags for highlighted keywords)
|
|
53
|
+
const title = titleMatch[1].replace(/<[^>]+>/g, '').trim();
|
|
54
|
+
const snippet = snippetMatch[2].replace(/<[^>]+>/g, '').trim();
|
|
55
|
+
|
|
56
|
+
// Clean DuckDuckGo's tracking wrapper from the URL
|
|
57
|
+
let url = snippetMatch[1];
|
|
58
|
+
if (url.startsWith('//duckduckgo.com/l/?uddg=')) {
|
|
59
|
+
url = decodeURIComponent(url.split('uddg=')[1].split('&')[0]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
results.push({ title, url, snippet });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (results.length === 0) {
|
|
67
|
+
return "No results found.";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return JSON.stringify(results, null, 2);
|
|
71
|
+
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (err.name === 'AbortError') {
|
|
74
|
+
return `Error: Request timed out after ${TIMEOUT_MS}ms`;
|
|
75
|
+
}
|
|
76
|
+
return `Error searching web: ${err.message}`;
|
|
77
|
+
}
|
|
78
|
+
}
|