From e867c8495f4d83e1c0bc217256d544a0635d9614 Mon Sep 17 00:00:00 2001 From: Alex Suraci Date: Mon, 7 Jul 2025 13:28:49 -0400 Subject: [PATCH] wip: file search/replace (needs tests) --- .container-use/environment.json | 3 +- environment/filesystem.go | 111 ++++++++++++++++++++++++++++++++ mcpserver/tools.go | 68 +++++++++++++++++++ 3 files changed, 181 insertions(+), 1 deletion(-) diff --git a/.container-use/environment.json b/.container-use/environment.json index 40b0007c..400318ba 100644 --- a/.container-use/environment.json +++ b/.container-use/environment.json @@ -8,5 +8,6 @@ "git config --global user.name \"Test User\"", "git config --global user.email \"test@dagger.com\"", "curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b /usr/local/bin v1.61.0" - ] + ], + "Locked": false } \ No newline at end of file diff --git a/environment/filesystem.go b/environment/filesystem.go index 9e3f19ee..a5009dc4 100644 --- a/environment/filesystem.go +++ b/environment/filesystem.go @@ -2,6 +2,7 @@ package environment import ( "context" + "crypto/sha256" "fmt" "strings" ) @@ -40,6 +41,78 @@ func (env *Environment) FileWrite(ctx context.Context, explanation, targetFile, return nil } +func (env *Environment) FileSearchReplace(ctx context.Context, explanation, targetFile, search, replace, matchID string) error { + contents, err := env.container().File(targetFile).Contents(ctx) + if err != nil { + return err + } + + // Find all matches of the search text + matches := []int{} + searchIndex := 0 + for { + index := strings.Index(contents[searchIndex:], search) + if index == -1 { + break + } + actualIndex := searchIndex + index + matches = append(matches, actualIndex) + searchIndex = actualIndex + 1 + } + + if len(matches) == 0 { + return fmt.Errorf("search text not found in file %s", targetFile) + } + + // If there are multiple matches and no matchID is provided, return an error with all matches + if len(matches) > 1 && matchID == "" { + var matchDescriptions []string + for i, matchIndex := range matches { + // Generate a unique ID for each match + id := generateMatchID(targetFile, search, replace, i) + + // Get context around the match (3 lines before and after) + context := getMatchContext(contents, matchIndex, len(search)) + + matchDescriptions = append(matchDescriptions, fmt.Sprintf("Match %d (ID: %s):\n%s", i+1, id, context)) + } + + return fmt.Errorf("multiple matches found for search text in %s. Please specify which_match parameter with one of the following IDs:\n\n%s", + targetFile, strings.Join(matchDescriptions, "\n\n")) + } + + // Determine which match to replace + var targetMatchIndex int + if len(matches) == 1 { + targetMatchIndex = matches[0] + } else { + // Find the match with the specified ID + found := false + for i, matchIndex := range matches { + id := generateMatchID(targetFile, search, replace, i) + if id == matchID { + targetMatchIndex = matchIndex + found = true + break + } + } + if !found { + return fmt.Errorf("match ID %s not found", matchID) + } + } + + // Replace the specific match + newContents := contents[:targetMatchIndex] + replace + contents[targetMatchIndex+len(search):] + + // Apply the changes + err = env.apply(ctx, env.container().WithNewFile(targetFile, newContents)) + if err != nil { + return fmt.Errorf("failed applying file edit, skipping git propagation: %w", err) + } + env.Notes.Add("Edit %s", targetFile) + return nil +} + func (env *Environment) FileDelete(ctx context.Context, explanation, targetFile string) error { err := env.apply(ctx, env.container().WithoutFile(targetFile)) if err != nil { @@ -60,3 +133,41 @@ func (env *Environment) FileList(ctx context.Context, path string) (string, erro } return out.String(), nil } + +// generateMatchID creates a unique ID for a match based on file, search, replace, and index +func generateMatchID(targetFile, search, replace string, index int) string { + data := fmt.Sprintf("%s:%s:%s:%d", targetFile, search, replace, index) + hash := sha256.Sum256([]byte(data)) + return fmt.Sprintf("%x", hash)[:8] // Use first 8 characters of hash +} + +// getMatchContext returns the context around a match (3 lines before and after) +func getMatchContext(contents string, matchIndex, matchLength int) string { + lines := strings.Split(contents, "\n") + + // Find which line contains the match + currentPos := 0 + matchLine := 0 + for i, line := range lines { + if currentPos+len(line) >= matchIndex { + matchLine = i + break + } + currentPos += len(line) + 1 // +1 for newline + } + + // Get context lines (3 before, match line, 3 after) + start := max(0, matchLine-3) + end := min(len(lines), matchLine+4) + + contextLines := make([]string, 0, end-start) + for i := start; i < end; i++ { + prefix := " " + if i == matchLine { + prefix = "> " // Mark the line containing the match + } + contextLines = append(contextLines, fmt.Sprintf("%s%s", prefix, lines[i])) + } + + return strings.Join(contextLines, "\n") +} diff --git a/mcpserver/tools.go b/mcpserver/tools.go index 9000f2cf..25b59b11 100644 --- a/mcpserver/tools.go +++ b/mcpserver/tools.go @@ -118,6 +118,7 @@ func init() { EnvironmentFileReadTool, EnvironmentFileListTool, EnvironmentFileWriteTool, + EnvironmentFilePatchTool, EnvironmentFileDeleteTool, EnvironmentAddServiceTool, @@ -662,6 +663,73 @@ var EnvironmentFileWriteTool = &Tool{ }, } +var EnvironmentFilePatchTool = &Tool{ + Definition: mcp.NewTool("environment_file_patch", + mcp.WithDescription("Find and replace text in a file."), + mcp.WithString("explanation", + mcp.Description("One sentence explanation for why this file is being edited."), + ), + mcp.WithString("environment_source", + mcp.Description("Absolute path to the source git repository for the environment."), + mcp.Required(), + ), + mcp.WithString("environment_id", + mcp.Description("The ID of the environment for this command. Must call `environment_create` first."), + mcp.Required(), + ), + mcp.WithString("target_file", + mcp.Description("Path of the file to write, absolute or relative to the workdir."), + mcp.Required(), + ), + mcp.WithString("search_text", + mcp.Description("The text to find and replace."), + mcp.Required(), + ), + mcp.WithString("replace_text", + mcp.Description("The text to insert."), + mcp.Required(), + ), + mcp.WithString("which_match", + mcp.Description("The ID of the match to replace, if there were multiple matches."), + ), + ), + Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + repo, env, err := openEnvironment(ctx, request) + if err != nil { + return mcp.NewToolResultErrorFromErr("unable to open the environment", err), nil + } + + targetFile, err := request.RequireString("target_file") + if err != nil { + return nil, err + } + search, err := request.RequireString("search_text") + if err != nil { + return nil, err + } + replace, err := request.RequireString("replace_text") + if err != nil { + return nil, err + } + + if err := env.FileSearchReplace(ctx, + request.GetString("explanation", ""), + targetFile, + search, + replace, + request.GetString("matchID", ""), + ); err != nil { + return mcp.NewToolResultErrorFromErr("failed to write file", err), nil + } + + if err := repo.Update(ctx, env, request.GetString("explanation", "")); err != nil { + return mcp.NewToolResultErrorFromErr("unable to update the environment", err), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("file %s edited successfully and committed to container-use/ remote", targetFile)), nil + }, +} + var EnvironmentFileDeleteTool = &Tool{ Definition: mcp.NewTool("environment_file_delete", mcp.WithDescription("Deletes a file at the specified path."),