Skip to content

Commit 9353899

Browse files
committed
feat: add search_and_replace tool for pattern-based file modifications
- Add new search_and_replace tool implementation - Update core files to support search/replace operations - Add tool description and documentation - Initialize cline_docs directory
1 parent ef8d02d commit 9353899

File tree

6 files changed

+241
-5
lines changed

6 files changed

+241
-5
lines changed

src/core/Cline.ts

Lines changed: 177 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -966,9 +966,10 @@ export class Cline {
966966
case "apply_diff":
967967
return `[${block.name} for '${block.params.path}']`
968968
case "search_files":
969-
return `[${block.name} for '${block.params.regex}'${
970-
block.params.file_pattern ? ` in '${block.params.file_pattern}'` : ""
971-
}]`
969+
return `[${block.name} for '${block.params.regex}'${block.params.file_pattern ? ` in '${block.params.file_pattern}'` : ""
970+
}]`
971+
case "search_and_replace":
972+
return `[${block.name} for '${block.params.path}`
972973
case "list_files":
973974
return `[${block.name} for '${block.params.path}']`
974975
case "list_code_definition_names":
@@ -1285,6 +1286,175 @@ export class Cline {
12851286
break
12861287
}
12871288
}
1289+
1290+
case "search_and_replace": {
1291+
const relPath: string | undefined = block.params.path
1292+
const operations: string | undefined = block.params.operations
1293+
1294+
const sharedMessageProps: ClineSayTool = {
1295+
tool: "appliedDiff",
1296+
path: getReadablePath(cwd, removeClosingTag("path", relPath)),
1297+
}
1298+
1299+
try {
1300+
if (block.partial) {
1301+
const partialMessage = JSON.stringify({
1302+
path: removeClosingTag("path", relPath),
1303+
operations: removeClosingTag("operations", operations)
1304+
})
1305+
await this.ask("tool", partialMessage, block.partial).catch(() => { })
1306+
break
1307+
} else {
1308+
if (!relPath) {
1309+
this.consecutiveMistakeCount++
1310+
pushToolResult(await this.sayAndCreateMissingParamError("search_and_replace", "path"))
1311+
break
1312+
}
1313+
if (!operations) {
1314+
this.consecutiveMistakeCount++
1315+
pushToolResult(await this.sayAndCreateMissingParamError("search_and_replace", "operations"))
1316+
break
1317+
}
1318+
1319+
const absolutePath = path.resolve(cwd, relPath)
1320+
const fileExists = await fileExistsAtPath(absolutePath)
1321+
1322+
if (!fileExists) {
1323+
this.consecutiveMistakeCount++
1324+
const formattedError = `File does not exist at path: ${absolutePath}\n\n<error_details>\nThe specified file could not be found. Please verify the file path and try again.\n</error_details>`
1325+
await this.say("error", formattedError)
1326+
pushToolResult(formattedError)
1327+
break
1328+
}
1329+
1330+
1331+
1332+
let parsedOperations: Array<{
1333+
search: string
1334+
replace: string
1335+
start_line?: number
1336+
end_line?: number
1337+
use_regex?: boolean
1338+
ignore_case?: boolean
1339+
regex_flags?: string
1340+
}>
1341+
1342+
try {
1343+
parsedOperations = JSON.parse(operations)
1344+
if (!Array.isArray(parsedOperations)) {
1345+
throw new Error("Operations must be an array")
1346+
}
1347+
} catch (error) {
1348+
this.consecutiveMistakeCount++
1349+
await this.say(
1350+
"error",
1351+
`Failed to parse operations JSON: ${error.message}`
1352+
)
1353+
pushToolResult(formatResponse.toolError("Invalid operations JSON format"))
1354+
break
1355+
}
1356+
1357+
// Read the original file content
1358+
const fileContent = await fs.readFile(absolutePath, 'utf-8')
1359+
const lines = fileContent.split('\n')
1360+
let newContent = fileContent
1361+
1362+
// Apply each search/replace operation
1363+
for (const op of parsedOperations) {
1364+
const searchPattern = op.use_regex
1365+
? new RegExp(op.search, op.regex_flags || (op.ignore_case ? 'gi' : 'g'))
1366+
: new RegExp(escapeRegExp(op.search), op.ignore_case ? 'gi' : 'g')
1367+
1368+
1369+
1370+
if (op.start_line || op.end_line) {
1371+
// Line-restricted replacement
1372+
const startLine = (op.start_line || 1) - 1
1373+
const endLine = (op.end_line || lines.length) - 1
1374+
1375+
const beforeLines = lines.slice(0, startLine)
1376+
const targetLines = lines.slice(startLine, endLine + 1)
1377+
const afterLines = lines.slice(endLine + 1)
1378+
1379+
const modifiedLines = targetLines.map(line =>
1380+
line.replace(searchPattern, op.replace)
1381+
)
1382+
1383+
newContent = [
1384+
...beforeLines,
1385+
...modifiedLines,
1386+
...afterLines
1387+
].join('\n')
1388+
} else {
1389+
// Global replacement
1390+
newContent = newContent.replace(searchPattern, op.replace)
1391+
}
1392+
}
1393+
1394+
this.consecutiveMistakeCount = 0
1395+
1396+
// Show diff preview
1397+
const diff = formatResponse.createPrettyPatch(
1398+
relPath,
1399+
this.diffViewProvider.originalContent,
1400+
newContent,
1401+
)
1402+
1403+
if (!diff) {
1404+
pushToolResult(`No changes needed for '${relPath}'`)
1405+
break
1406+
}
1407+
1408+
await this.diffViewProvider.open(relPath);
1409+
await this.diffViewProvider.update(newContent, true);
1410+
this.diffViewProvider.scrollToFirstDiff();
1411+
1412+
const completeMessage = JSON.stringify({
1413+
...sharedMessageProps,
1414+
diff: diff,
1415+
} satisfies ClineSayTool)
1416+
1417+
const didApprove = await askApproval("tool", completeMessage)
1418+
if (!didApprove) {
1419+
await this.diffViewProvider.revertChanges() // This likely handles closing the diff view
1420+
break
1421+
}
1422+
1423+
const { newProblemsMessage, userEdits, finalContent } =
1424+
await this.diffViewProvider.saveChanges()
1425+
this.didEditFile = true // used to determine if we should wait for busy terminal to update before sending api request
1426+
if (userEdits) {
1427+
await this.say(
1428+
"user_feedback_diff",
1429+
JSON.stringify({
1430+
tool: fileExists ? "editedExistingFile" : "newFileCreated",
1431+
path: getReadablePath(cwd, relPath),
1432+
diff: userEdits,
1433+
} satisfies ClineSayTool),
1434+
)
1435+
pushToolResult(
1436+
`The user made the following updates to your content:\n\n${userEdits}\n\n` +
1437+
`The updated content, which includes both your original modifications and the user's edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file, including line numbers:\n\n` +
1438+
`<final_file_content path="${relPath.toPosix()}">\n${addLineNumbers(finalContent || '')}\n</final_file_content>\n\n` +
1439+
`Please note:\n` +
1440+
`1. You do not need to re-write the file with these changes, as they have already been applied.\n` +
1441+
`2. Proceed with the task using this updated file content as the new baseline.\n` +
1442+
`3. If the user's edits have addressed part of the task or changed the requirements, adjust your approach accordingly.` +
1443+
`${newProblemsMessage}`,
1444+
)
1445+
} else {
1446+
pushToolResult(`Changes successfully applied to ${relPath.toPosix()}:\n\n${newProblemsMessage}`)
1447+
}
1448+
await this.diffViewProvider.reset()
1449+
break
1450+
}
1451+
} catch (error) {
1452+
await handleError("applying search and replace", error)
1453+
await this.diffViewProvider.reset()
1454+
break
1455+
}
1456+
}
1457+
12881458
case "apply_diff": {
12891459
const relPath: string | undefined = block.params.path
12901460
const diffContent: string | undefined = block.params.diff
@@ -2588,4 +2758,8 @@ export class Cline {
25882758

25892759
return `<environment_details>\n${details.trim()}\n</environment_details>`
25902760
}
2761+
}
2762+
2763+
function escapeRegExp(string: string): string {
2764+
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
25912765
}

src/core/assistant-message/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export const toolUseNames = [
2121
"access_mcp_resource",
2222
"ask_followup_question",
2323
"attempt_completion",
24+
"search_and_replace"
2425
] as const
2526

2627
// Converts array of tool call names into a union type ("execute_command" | "read_file" | ...)
@@ -47,6 +48,7 @@ export const toolParamNames = [
4748
"diff",
4849
"start_line",
4950
"end_line",
51+
"operations"
5052
] as const
5153

5254
export type ToolParamName = (typeof toolParamNames)[number]

src/core/prompts/sections/rules.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ RULES
1515
- Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '${cwd.toPosix()}', and if so prepend with \`cd\`'ing into that directory && then executing the command (as one command since you are stuck operating from '${cwd.toPosix()}'). For example, if you needed to run \`npm install\` in a project outside of '${cwd.toPosix()}', you would need to prepend with a \`cd\` i.e. pseudocode for this would be \`cd (path to project) && (command, in this case npm install)\`.
1616
- When using the search_files tool, craft your regex patterns carefully to balance specificity and flexibility. Based on the user's task you may use it to find code patterns, TODO comments, function definitions, or any text-based information across the project. The results include context, so analyze the surrounding code to better understand the matches. Leverage the search_files tool in combination with other tools for more comprehensive analysis. For example, use it to find specific code patterns, then use read_file to examine the full context of interesting matches before using write_to_file to make informed changes.
1717
- When creating a new project (such as an app, website, or any software project), organize all new files within a dedicated project directory unless the user specifies otherwise. Use appropriate file paths when writing files, as the write_to_file tool will automatically create any necessary directories. Structure the project logically, adhering to best practices for the specific type of project being created. Unless otherwise specified, new projects should be easily run without additional setup, for example most projects can be built in HTML, CSS, and JavaScript - which you can open in a browser.
18+
- You should use search_and_replace when suggesting edits that involve either small individual changes or pattern-based modifications that need to be applied consistently across a file.
1819
${diffStrategy ? "- You should use apply_diff instead of write_to_file when making changes to existing files since it is much faster and easier to apply a diff than to write the entire file again. Only use write_to_file to edit files when apply_diff has failed repeatedly to apply the diff." : "- When you want to modify a file, use the write_to_file tool directly with the desired content. You do not need to display the content before using the tool."}
1920
- Be sure to consider the type of project (e.g. Python, JavaScript, web application) when determining the appropriate structure and files to include. Also consider what files may be most relevant to accomplishing the task, for example looking at a project's manifest file would help you understand the project's dependencies, which you could incorporate into any code you write.
2021
- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices.

src/core/prompts/tools/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { getAskFollowupQuestionDescription } from './ask-followup-question'
99
import { getAttemptCompletionDescription } from './attempt-completion'
1010
import { getUseMcpToolDescription } from './use-mcp-tool'
1111
import { getAccessMcpResourceDescription } from './access-mcp-resource'
12+
import { getSearchAndReplaceDescription } from './search-and-replace'
1213
import { DiffStrategy } from '../../diff/DiffStrategy'
1314
import { McpHub } from '../../../services/mcp/McpHub'
1415
import { Mode, codeMode, askMode } from '../modes'
@@ -59,6 +60,9 @@ export function getToolDescriptionsForMode(
5960
if (hasAllowedTool(allowedTools, 'list_code_definition_names')) {
6061
descriptions.push(getListCodeDefinitionNamesDescription(cwd));
6162
}
63+
if (hasAllowedTool(allowedTools, 'search_and_replace')) {
64+
descriptions.push(getSearchAndReplaceDescription(cwd));
65+
}
6266

6367
// Browser actions
6468
if (supportsComputerUse && hasAllowedTool(allowedTools, 'browser_action')) {
@@ -97,5 +101,6 @@ export {
97101
getAskFollowupQuestionDescription,
98102
getAttemptCompletionDescription,
99103
getUseMcpToolDescription,
100-
getAccessMcpResourceDescription
104+
getAccessMcpResourceDescription,
105+
getSearchAndReplaceDescription
101106
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
export function getSearchAndReplaceDescription(cwd: string): string {
2+
return `## search_and_replace
3+
Description: Request to perform search and replace operations on a file. Each operation can specify a search pattern (string or regex) and replacement text, with optional line range restrictions and regex flags. Shows a diff preview before applying changes.
4+
Parameters:
5+
- path: (required) The path of the file to modify (relative to the current working directory ${cwd})
6+
- operations: (required) A JSON array of search/replace operations. Each operation is an object with:
7+
* search: (required) The text or pattern to search for
8+
* replace: (required) The text to replace matches with
9+
* start_line: (optional) Starting line number for restricted replacement
10+
* end_line: (optional) Ending line number for restricted replacement
11+
* use_regex: (optional) Whether to treat search as a regex pattern
12+
* ignore_case: (optional) Whether to ignore case when matching
13+
* regex_flags: (optional) Additional regex flags when use_regex is true
14+
15+
Usage:
16+
<search_and_replace>
17+
<path>File path here</path>
18+
<operations>[
19+
{
20+
"search": "text to find",
21+
"replace": "replacement text",
22+
"start_line": 1,
23+
"end_line": 10
24+
}
25+
]</operations>
26+
</search_and_replace>
27+
28+
Example: Replace "foo" with "bar" in lines 1-10 of example.ts
29+
<search_and_replace>
30+
<path>example.ts</path>
31+
<operations>[
32+
{
33+
"search": "foo",
34+
"replace": "bar",
35+
"start_line": 1,
36+
"end_line": 10
37+
}
38+
]</operations>
39+
</search_and_replace>
40+
41+
Example: Replace all occurrences of "old" with "new" using regex
42+
<search_and_replace>
43+
<path>example.ts</path>
44+
<operations>[
45+
{
46+
"search": "old\\w+",
47+
"replace": "new$&",
48+
"use_regex": true,
49+
"ignore_case": true
50+
}
51+
]</operations>
52+
</search_and_replace>`
53+
}

src/core/tool-lists.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ export const CODE_ALLOWED_TOOLS = [
2424
'use_mcp_tool',
2525
'access_mcp_resource',
2626
'ask_followup_question',
27-
'attempt_completion'
27+
'attempt_completion',
28+
'search_and_replace'
2829
] as const;
2930

3031
// Tool name types for type safety

0 commit comments

Comments
 (0)