diff --git a/cmd/container-use/stdio.go b/cmd/container-use/stdio.go index 9aa560fa..af83a9ed 100644 --- a/cmd/container-use/stdio.go +++ b/cmd/container-use/stdio.go @@ -1,10 +1,12 @@ package main import ( + "context" "log/slog" "os" "dagger.io/dagger" + "github.com/dagger/container-use/environment" "github.com/dagger/container-use/mcpserver" "github.com/spf13/cobra" ) @@ -30,10 +32,16 @@ var stdioCmd = &cobra.Command{ } defer dag.Close() + go warmCache(ctx, dag) return mcpserver.RunStdioServer(ctx, dag) }, } +func warmCache(ctx context.Context, dag *dagger.Client) { + environment.EditUtil(dag).Sync(ctx) + environment.GrepUtil(dag).Sync(ctx) +} + func init() { rootCmd.AddCommand(stdioCmd) } diff --git a/edit/cmd/edit/main.go b/edit/cmd/edit/main.go new file mode 100644 index 00000000..90d1c5d0 --- /dev/null +++ b/edit/cmd/edit/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "fmt" + "io" + "os" + "strconv" + + "github.com/tiborvass/replace" + "golang.org/x/text/transform" +) + +func main() { + if len(os.Args)%3 != 0 || len(os.Args) < 6 { + fmt.Fprintf(os.Stderr, "usage: %s [... ]\n", os.Args[0]) + fmt.Fprintln(os.Stderr, " Reads stream from source and replaces in it replace_count times, old_string with new_string and writes to destination.") + fmt.Fprintln(os.Stderr, " If replace_count is -1, it replaces all occurrences.") + os.Exit(1) + } + n := len(os.Args)/3 - 1 + t := make([]transform.Transformer, n) + for i := range t { + replaceCount, err := strconv.Atoi(os.Args[5+i]) + if err != nil { + fmt.Fprintf(os.Stderr, "replace_count must be an integer, received: %q\n", replaceCount) + } + t[i] = replace.StringN(os.Args[3+i], os.Args[4+i], replaceCount) + } + src, err := os.Open(os.Args[1]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + } + dest, err := os.Create(os.Args[2]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return + } + io.Copy(dest, replace.Chain(src, t...)) +} diff --git a/edit/edit.go b/edit/edit.go new file mode 100644 index 00000000..fa7ac0c6 --- /dev/null +++ b/edit/edit.go @@ -0,0 +1,12 @@ +package edit + +import _ "embed" + +//go:embed cmd/edit/main.go +var Src string + +//go:embed go.mod +var GoMod string + +//go:embed go.sum +var GoSum string diff --git a/edit/go.mod b/edit/go.mod new file mode 100644 index 00000000..e1cea250 --- /dev/null +++ b/edit/go.mod @@ -0,0 +1,10 @@ +module github.com/dagger/container-use + +go 1.24.3 + +require ( + github.com/tiborvass/replace v0.0.0-20250708165616-d642c0f9c3ff + golang.org/x/text v0.26.0 +) + +require github.com/google/go-cmp v0.6.0 // indirect diff --git a/edit/go.sum b/edit/go.sum new file mode 100644 index 00000000..7c8f0c01 --- /dev/null +++ b/edit/go.sum @@ -0,0 +1,20 @@ +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/tiborvass/replace v0.0.0-20250708165616-d642c0f9c3ff h1:zpP7bpKTsqLLgsUngIXlmd1X9PpJD1Bca1dQfYacNf8= +github.com/tiborvass/replace v0.0.0-20250708165616-d642c0f9c3ff/go.mod h1:9+jQ4zDLeiANhfwMbvl6qKw/sW+aN1m6AjhxHZKN40s= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +gotest.tools/v3 v3.0.2 h1:kG1BFyqVHuQoVQiR1bWGnfz/fmHvvuiSPIV7rvl360E= +gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= diff --git a/environment/config.go b/environment/config.go index 339085cd..14b573f5 100644 --- a/environment/config.go +++ b/environment/config.go @@ -9,8 +9,12 @@ import ( ) const ( - defaultImage = "ubuntu:24.04" - alpineImage = "alpine:3.21.3@sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c" + defaultImage = "ubuntu:24.04" + + // WARNING: for maximum efficiency, please ensure golangImage is always based on this specific alpineImage + alpineImage = "alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715" + golangImage = "golang:1.24.5-alpine@sha256:ddf52008bce1be455fe2b22d780b6693259aaf97b16383b6372f4b22dd33ad66" + configDir = ".container-use" environmentFile = "environment.json" ) diff --git a/environment/filesystem.go b/environment/filesystem.go index 84ead062..123ab982 100644 --- a/environment/filesystem.go +++ b/environment/filesystem.go @@ -2,8 +2,12 @@ package environment import ( "context" + _ "embed" "fmt" "strings" + + "dagger.io/dagger" + "github.com/dagger/container-use/edit" ) func (env *Environment) FileRead(ctx context.Context, targetFile string, shouldReadEntireFile bool, startLineOneIndexedInclusive int, endLineOneIndexedInclusive int) (string, error) { @@ -36,7 +40,7 @@ func (env *Environment) FileRead(ctx context.Context, targetFile string, shouldR return strings.Join(lines[start:end], "\n"), nil } -func (env *Environment) FileWrite(ctx context.Context, explanation, targetFile, contents string) error { +func (env *Environment) FileWrite(ctx context.Context, targetFile, contents string) error { err := env.apply(ctx, env.container().WithNewFile(targetFile, contents)) if err != nil { return fmt.Errorf("failed applying file write, skipping git propagation: %w", err) @@ -45,7 +49,7 @@ func (env *Environment) FileWrite(ctx context.Context, explanation, targetFile, return nil } -func (env *Environment) FileDelete(ctx context.Context, explanation, targetFile string) error { +func (env *Environment) FileDelete(ctx context.Context, targetFile string) error { err := env.apply(ctx, env.container().WithoutFile(targetFile)) if err != nil { return fmt.Errorf("failed applying file delete, skipping git propagation: %w", err) @@ -54,8 +58,18 @@ func (env *Environment) FileDelete(ctx context.Context, explanation, targetFile return nil } -func (env *Environment) FileList(ctx context.Context, path string) (string, error) { - entries, err := env.container().Directory(path).Entries(ctx) +func (env *Environment) FileList(ctx context.Context, path string, ignore []string) (string, error) { + filter := dagger.DirectoryFilterOpts{Exclude: ignore} + return env.ls(ctx, path, filter) +} + +func (env *Environment) FileGlob(ctx context.Context, path string, pattern string) (string, error) { + filter := dagger.DirectoryFilterOpts{Include: []string{pattern}} + return env.ls(ctx, path, filter) +} + +func (env *Environment) ls(ctx context.Context, path string, filter dagger.DirectoryFilterOpts) (string, error) { + entries, err := env.container().Directory(path).Filter(filter).Entries(ctx) if err != nil { return "", err } @@ -65,3 +79,60 @@ func (env *Environment) FileList(ctx context.Context, path string) (string, erro } return out.String(), nil } + +func (env *Environment) FileGrep(ctx context.Context, path, pattern, include string) (string, error) { + // Hack: use busybox to run `sed` since dagger doesn't have native file editing primitives. + args := []string{"/usr/bin/rg", "--no-unicode", "-g", include, "--", pattern, "."} + + dir := env.container().Directory(path) + out, err := GrepUtil(env.dag). + WithMountedDirectory("/workdir", dir). + WithWorkdir("/workdir"). + WithExec(args, dagger.ContainerWithExecOpts{Expect: dagger.ReturnTypeAny}). + Stdout(ctx) + if err != nil { + return "", err + } + return out, nil +} + +type FileEdit struct { + OldString string + NewString string + ReplaceAll bool +} + +func EditUtil(dag *dagger.Client) *dagger.Container { + editBin := dag.Container().From(golangImage). + WithNewFile("/go/src/edit.go", edit.Src). + WithNewFile("/go/src/go.mod", edit.GoMod). + WithNewFile("/go/src/go.sum", edit.GoSum). + WithEnvVariable("CGO_ENABLED", "0"). + WithWorkdir("/go/src"). + WithExec([]string{"go", "build", "-o", "/edit", "-ldflags", "-w -s", "/go/src/edit.go"}).File("/edit") + return dag.Container().From(alpineImage).WithFile("/edit", editBin).WithEntrypoint([]string{"/edit"}) +} + +func GrepUtil(dag *dagger.Client) *dagger.Container { + return dag.Container().From(alpineImage).WithExec([]string{"apk", "add", "-U", "ripgrep"}) +} + +func (env *Environment) FileEdit(ctx context.Context, targetFile string, edits []FileEdit) error { + // Hack: use busybox to run `sed` since dagger doesn't have native file editing primitives. + args := []string{"/edit", "/target", "/new"} + for _, edit := range edits { + replaceCount := "1" + if edit.ReplaceAll { + replaceCount = "-1" + } + args = append(args, edit.OldString, edit.NewString, replaceCount) + } + + newFile := EditUtil(env.dag).WithMountedFile("/target", env.container().File(targetFile)).WithExec(args).File("/new") + err := env.apply(ctx, env.container().WithFile(targetFile, newFile)) + if err != nil { + return fmt.Errorf("failed applying file edit, skipping git propagation: %w", err) + } + env.Notes.Add("Edit %s", targetFile) + return nil +} diff --git a/environment/integration/integration_test.go b/environment/integration/integration_test.go index 938b067b..b2ced348 100644 --- a/environment/integration/integration_test.go +++ b/environment/integration/integration_test.go @@ -384,7 +384,7 @@ func TestWeirdUserScenarios(t *testing.T) { defer repo1.Delete(ctx, env1.ID) // Write file in env1 - err = env1.FileWrite(ctx, "Add file", "app.js", "console.log('repo1');") + err = env1.FileWrite(ctx, "app.js", "console.log('repo1');") require.NoError(t, err) // Try to use env1 while in repo2 (should fail) diff --git a/go.mod b/go.mod index 461f6838..b8388ce2 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.5 github.com/charmbracelet/fang v0.3.0 github.com/charmbracelet/lipgloss v1.1.0 + github.com/dagger/container-use/edit v0.0.0-00010101000000-000000000000 github.com/dustin/go-humanize v1.0.1 github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0 github.com/mark3labs/mcp-go v0.29.0 @@ -87,3 +88,5 @@ require ( google.golang.org/grpc v1.73.0 // indirect google.golang.org/protobuf v1.36.6 // indirect ) + +replace github.com/dagger/container-use/edit => ./edit diff --git a/go.sum b/go.sum index 44d4f368..e261e189 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -dagger.io/dagger v0.18.11 h1:6lSfemlbGM2HmdOjhgevrX2+orMDGKU/xTaBMZ+otyY= -dagger.io/dagger v0.18.11/go.mod h1:azlZ24m2br95t0jQHUBpL5SiafeqtVDLl1Itlq6GO+4= dagger.io/dagger v0.18.12 h1:s7v8aHlzDUogZ/jW92lHC+gljCNRML+0mosfh13R4vs= dagger.io/dagger v0.18.12/go.mod h1:azlZ24m2br95t0jQHUBpL5SiafeqtVDLl1Itlq6GO+4= github.com/99designs/gqlgen v0.17.75 h1:GwHJsptXWLHeY7JO8b7YueUI4w9Pom6wJTICosDtQuI= diff --git a/mcpserver/tools.go b/mcpserver/tools.go index 1433ab24..e473bd92 100644 --- a/mcpserver/tools.go +++ b/mcpserver/tools.go @@ -134,9 +134,12 @@ func init() { EnvironmentRunCmdTool, - EnvironmentFileReadTool, EnvironmentFileListTool, + EnvironmentFileGlobTool, + EnvironmentFileGrepTool, + EnvironmentFileReadTool, EnvironmentFileWriteTool, + EnvironmentFileEditTool, EnvironmentFileDeleteTool, EnvironmentAddServiceTool, @@ -547,11 +550,15 @@ var EnvironmentFileReadTool = &Tool{ var EnvironmentFileListTool = &Tool{ Definition: newEnvironmentTool( "environment_file_list", - "List the contents of a directory", + "List files and directories in a given path. You can optionally provide an array of glob patterns to ignore with the ignore parameter. You should generally prefer the environment_file_glob and environment_file_grep tools, if you know which directories to search.", mcp.WithString("path", mcp.Description("Path of the directory to list contents of, absolute or relative to the workdir"), mcp.Required(), ), + mcp.WithArray("ignore", + mcp.Description("List of glob patterns to ignore"), + mcp.Items(map[string]any{"type": "string"}), + ), ), Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { _, env, err := openEnvironment(ctx, request) @@ -563,8 +570,16 @@ var EnvironmentFileListTool = &Tool{ if err != nil { return nil, err } - - out, err := env.FileList(ctx, path) + args := request.GetArguments() + v, ok := args["ignore"] + var ignore []string + if ok { + ignore, ok = v.([]string) + if !ok { + return nil, fmt.Errorf("`ignore` argument expects an array of strings") + } + } + out, err := env.FileList(ctx, path, ignore) if err != nil { return nil, fmt.Errorf("failed to list directory: %w", err) } @@ -576,7 +591,7 @@ var EnvironmentFileListTool = &Tool{ var EnvironmentFileWriteTool = &Tool{ Definition: newEnvironmentTool( "environment_file_write", - "Write the contents of a file.", + "Write the full contents of a file.", mcp.WithString("target_file", mcp.Description("Path of the file to write, absolute or relative to the workdir."), mcp.Required(), @@ -601,7 +616,7 @@ var EnvironmentFileWriteTool = &Tool{ return nil, err } - if err := env.FileWrite(ctx, request.GetString("explanation", ""), targetFile, contents); err != nil { + if err := env.FileWrite(ctx, targetFile, contents); err != nil { return nil, fmt.Errorf("failed to write file: %w", err) } @@ -613,6 +628,135 @@ var EnvironmentFileWriteTool = &Tool{ }, } +var EnvironmentFileGlobTool = &Tool{ + Definition: newEnvironmentTool( + "environment_file_glob", + "Fast file pattern matching tool.\nSupports glob syntax \"**/*.js\" or \"src/**/*.ts\".\nReturns matching file paths.", + mcp.WithString("path", + mcp.Description("Path of the directory to search in, absolute or relative to the workdir"), + mcp.Required(), + ), + mcp.WithString("pattern", + mcp.Description("The glob pattern to match file paths against."), + mcp.Required(), + ), + ), + Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + _, env, err := openEnvironment(ctx, request) + if err != nil { + return mcp.NewToolResultErrorFromErr("unable to open the environment", err), nil + } + + path, err := request.RequireString("path") + if err != nil { + return nil, err + } + + pattern, err := request.RequireString("pattern") + if err != nil { + return nil, err + } + + out, err := env.FileGlob(ctx, path, pattern) + if err != nil { + return mcp.NewToolResultErrorFromErr("failed to list directory", err), nil + } + + return mcp.NewToolResultText(out), nil + }, +} + +var EnvironmentFileGrepTool = &Tool{ + Definition: newEnvironmentTool( + "environment_file_grep", + "Fast file content search using regular expressions.\nSupports full regex syntax (eg. \"log.*Error\", \"function\\s+\\w+\", etc.).\nReturns matching file paths.", + mcp.WithString("path", + mcp.Description("Path of the directory to search in, absolute or relative to the workdir"), + mcp.Required(), + ), + mcp.WithString("pattern", + mcp.Description("The regular expression pattern to search for in file contents."), + mcp.Required(), + ), + mcp.WithString("include", + mcp.Description("File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")."), + ), + ), + Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + _, env, err := openEnvironment(ctx, request) + if err != nil { + return mcp.NewToolResultErrorFromErr("unable to open the environment", err), nil + } + + path, err := request.RequireString("path") + if err != nil { + return nil, err + } + + pattern, err := request.RequireString("pattern") + if err != nil { + return nil, err + } + + include := request.GetString("include", "") + + out, err := env.FileGrep(ctx, path, pattern, include) + if err != nil { + return mcp.NewToolResultErrorFromErr("failed to grep directory", err), nil + } + if out == "" { + out = "No files found matching the pattern." + } + + return mcp.NewToolResultText(out), nil + }, +} + +var EnvironmentFileEditTool = &Tool{ + Definition: newEnvironmentTool( + "environment_file_edit", + "This is a tool for making single or multiple edits to a single file in one operation. It allows you to perform multiple find-and-replace operations efficiently.\n\nBefore using this tool:\n\n1. Use the environment_file_read tool to understand the file's contents and context\n2. Verify the directory path is correct\n\nTo make file edits, provide the following:\n1. file_path: The relative path to the file to modify (must be relative, not absolute)\n2. edits: An array of edit operations to perform, where each edit contains:\n - old_string: The text to replace (must match the file contents exactly, including all whitespace and indentation)\n - new_string: The edited text to replace the old_string\n - replace_all: Replace all occurences of old_string. This parameter is optional and defaults to false.\n\nIMPORTANT:\n- All edits are applied in sequence, in the order they are provided\n- Each edit operates on the result of the previous edit\n- All edits must be valid for the operation to succeed - if any edit fails, none will be applied\n- This tool is ideal when you need to make several changes to different parts of the same file\n- For Jupyter notebooks (.ipynb files), use the NotebookEdit instead\n\nCRITICAL REQUIREMENTS:\n1. All edits follow the same requirements as the single Edit tool\n2. The edits are atomic - either all succeed or none are applied\n3. Plan your edits carefully to avoid conflicts between sequential operations\n\nWARNING:\n- The tool will fail if edits.old_string doesn't match the file contents exactly (including whitespace)\n- The tool will fail if edits.old_string and edits.new_string are the same\n- Since edits are applied in sequence, ensure that earlier edits don't affect the text that later edits are trying to find\n\nWhen making edits:\n- Ensure all edits result in idiomatic, correct code\n- Do not leave the code in a broken state\n- Always use absolute file paths (starting with /)\n- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.\n- Use replace_all for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.\n\nIf you want to create a new file, use:\n- A new file path, including dir name if needed\n- First edit: empty old_string and the new file's contents as new_string\n- Subsequent edits: normal edit operations on the created content", + mcp.WithString("target_file", + mcp.Description("Path of the file to edit, absolute or relative to the workdir."), + mcp.Required(), + ), + mcp.WithArray("edits", + mcp.Description("An array of edit operations to perform on the contents of target_file."), + mcp.Items(map[string]any{"type": "object", "properties": map[string]any{ + "old_string": map[string]any{"type": "string", "description": "The text to replace"}, + "new_string": map[string]any{"type": "string", "description": "The text to replace it with"}, + "replace_all": map[string]any{"type": "string", "description": "Replace all occurences of old_string (default false)", "default": false}, + }}), + mcp.MinItems(1), + mcp.Required(), + ), + ), + 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 + } + + var args struct { + TargetFile string + Edits []environment.FileEdit + } + if err := request.BindArguments(&args); err != nil { + return nil, fmt.Errorf("could not bind arguments") + } + + if err := env.FileEdit(ctx, args.TargetFile, args.Edits); err != nil { + return mcp.NewToolResultErrorFromErr("failed to edit 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", args.TargetFile)), nil + }, +} + var EnvironmentFileDeleteTool = &Tool{ Definition: newEnvironmentTool( "environment_file_delete", @@ -633,7 +777,7 @@ var EnvironmentFileDeleteTool = &Tool{ return nil, err } - if err := env.FileDelete(ctx, request.GetString("explanation", ""), targetFile); err != nil { + if err := env.FileDelete(ctx, targetFile); err != nil { return nil, fmt.Errorf("failed to delete file: %w", err) }