Skip to content

Commit 451c470

Browse files
committed
edit: write an efficient text search-replace binary
This prevents streaming all the contents of a file from dagger. Signed-off-by: Tibor Vass <teabee89@gmail.com>
1 parent ca144d7 commit 451c470

7 files changed

Lines changed: 97 additions & 19 deletions

File tree

cmd/cu/stdio.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package main
22

33
import (
4+
"context"
45
"log/slog"
56
"os"
67

78
"dagger.io/dagger"
9+
"github.com/dagger/container-use/environment"
810
"github.com/dagger/container-use/mcpserver"
911
"github.com/spf13/cobra"
1012
)
@@ -30,10 +32,15 @@ var stdioCmd = &cobra.Command{
3032
}
3133
defer dag.Close()
3234

35+
go warmCache(ctx, dag)
3336
return mcpserver.RunStdioServer(ctx, dag)
3437
},
3538
}
3639

40+
func warmCache(ctx context.Context, dag *dagger.Client) {
41+
environment.EditUtil(dag).Sync(ctx)
42+
}
43+
3744
func init() {
3845
rootCmd.AddCommand(stdioCmd)
3946
}

edit/cmd/edit/main.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"os"
7+
"strconv"
8+
9+
"github.com/tiborvass/replace"
10+
"golang.org/x/text/transform"
11+
)
12+
13+
func main() {
14+
if len(os.Args)%3 != 0 || len(os.Args) < 6 {
15+
fmt.Fprintf(os.Stderr, "usage: %s <source> <destination> <old_string1> <new_string1> <replace_count1> [...<old_stringN> <new_stringN> <replace_countN>]\n", os.Args[0])
16+
fmt.Fprintln(os.Stderr, " Reads stream from source and replaces in it replace_count times, old_string with new_string and writes to destination.")
17+
fmt.Fprintln(os.Stderr, " If replace_count is -1, it replaces all occurrences.")
18+
os.Exit(1)
19+
}
20+
n := len(os.Args)/3 - 1
21+
t := make([]transform.Transformer, n)
22+
for i := range t {
23+
replaceCount, err := strconv.Atoi(os.Args[5+i])
24+
if err != nil {
25+
fmt.Fprintf(os.Stderr, "replace_count must be an integer, received: %q\n", replaceCount)
26+
}
27+
t[i] = replace.StringN(os.Args[3+i], os.Args[4+i], replaceCount)
28+
}
29+
io.Copy(os.Stdout, replace.Chain(os.Stdin, t...))
30+
}

edit/edit.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package edit
2+
3+
import _ "embed"
4+
5+
//go:embed cmd/edit/main.go
6+
var Src string

environment/filesystem.go

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,17 @@ package environment
22

33
import (
44
"context"
5+
_ "embed"
56
"fmt"
67
"strings"
78

89
"dagger.io/dagger"
910
"dagger.io/dagger/dag"
11+
"github.com/dagger/container-use/edit"
1012
)
1113

1214
// FIXME: See hack where it's used
13-
const fileEditBaseImage = "busybox"
15+
const fileUtilsBaseImage = "busybox"
1416

1517
func (env *Environment) FileRead(ctx context.Context, targetFile string, shouldReadEntireFile bool, startLineOneIndexed int, endLineOneIndexedInclusive int) (string, error) {
1618
file, err := env.container().File(targetFile).Contents(ctx)
@@ -82,18 +84,39 @@ func (env *Environment) FileGrep(ctx context.Context, path, pattern, include str
8284
args := []string{"/bin/grep", "-E", "--", pattern, include}
8385

8486
dir := env.container().Rootfs().Directory(path)
85-
out, err := dag.Container().From(fileEditBaseImage).WithMountedDirectory("/mnt", dir).WithWorkdir("/mnt").WithExec(args).Stdout(ctx)
87+
out, err := dag.Container().From(fileUtilsBaseImage).WithMountedDirectory("/mnt", dir).WithWorkdir("/mnt").WithExec(args).Stdout(ctx)
8688
if err != nil {
8789
return "", err
8890
}
8991
return out, nil
9092
}
9193

92-
func (env *Environment) FileEdit(ctx context.Context, targetFile string, edits []string) error {
94+
type FileEdit struct {
95+
Old string
96+
New string
97+
ReplaceAll bool
98+
}
99+
100+
func EditUtil(dag *dagger.Client) *dagger.Container {
101+
editBin := dag.Container().From("golang:alpine").
102+
WithNewFile("/edit.go", edit.Src).
103+
WithEnvVariable("CGO_ENABLED", "0").
104+
WithExec([]string{"go", "build", "-o", "/edit", "-ldflags", "-w -s", "/edit.go"}).File("/edit")
105+
return dag.Container().From("scratch").WithFile("/edit", editBin).WithEntrypoint([]string{"/edit"})
106+
}
107+
108+
func (env *Environment) FileEdit(ctx context.Context, dag *dagger.Client, targetFile string, edits []FileEdit) error {
93109
// Hack: use busybox to run `sed` since dagger doesn't have native file editing primitives.
94-
args := []string{"/bin/sh", "-c", fmt.Sprintf("sed -ri'' -- %s /target && cp /target /new", strings.Join(edits, " "))}
110+
args := []string{"/edit", "/target", "/new"}
111+
for _, edit := range edits {
112+
replaceCount := "1"
113+
if edit.ReplaceAll {
114+
replaceCount = "-1"
115+
}
116+
args = append(args, edit.Old, edit.New, replaceCount)
117+
}
95118

96-
newFile := dag.Container().From(fileEditBaseImage).WithMountedFile("/target", env.container().File(targetFile)).WithExec(args).File("/new")
119+
newFile := EditUtil(dag).WithMountedFile("/target", env.container().File(targetFile)).WithExec(args).File("/new")
97120
err := env.apply(ctx, env.container().WithFile(targetFile, newFile))
98121
if err != nil {
99122
return fmt.Errorf("failed applying file edit, skipping git propagation: %w", err)

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ require (
1313
github.com/spf13/cobra v1.9.1
1414
github.com/stretchr/testify v1.10.0
1515
github.com/tiborvass/go-watch v0.0.0-20250607214558-08999a83bf8b
16+
github.com/tiborvass/replace v0.0.0-20250708165616-d642c0f9c3ff
17+
golang.org/x/text v0.26.0
1618
)
1719

1820
require (
@@ -55,7 +57,6 @@ require (
5557
golang.org/x/sync v0.15.0 // indirect
5658
golang.org/x/sys v0.33.0 // indirect
5759
golang.org/x/term v0.32.0 // indirect
58-
golang.org/x/text v0.26.0 // indirect
5960
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
6061
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
6162
google.golang.org/grpc v1.73.0 // indirect

go.sum

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
2727
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
2828
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
2929
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
30+
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
3031
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
3132
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
3233
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -46,6 +47,8 @@ github.com/mark3labs/mcp-go v0.29.0 h1:sH1NBcumKskhxqYzhXfGc201D7P76TVXiT0fGVhab
4647
github.com/mark3labs/mcp-go v0.29.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
4748
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
4849
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
50+
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
51+
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
4952
github.com/pkg/term v1.1.0 h1:xIAAdCMh3QIAy+5FrE8Ad8XoDhEU4ufwbaSozViP9kk=
5053
github.com/pkg/term v1.1.0/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw=
5154
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -61,6 +64,7 @@ github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
6164
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
6265
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
6366
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
67+
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
6468
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
6569
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
6670
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -69,6 +73,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
6973
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
7074
github.com/tiborvass/go-watch v0.0.0-20250607214558-08999a83bf8b h1:W24fsALOtQ9v3b0mK4yR8wrmhPx4lqJAMMJ+d338fqM=
7175
github.com/tiborvass/go-watch v0.0.0-20250607214558-08999a83bf8b/go.mod h1:oAWYkECp9mFVuJQQzHtoHhepQKbme1gLM4fYH0KWvzk=
76+
github.com/tiborvass/replace v0.0.0-20250708165616-d642c0f9c3ff h1:zpP7bpKTsqLLgsUngIXlmd1X9PpJD1Bca1dQfYacNf8=
77+
github.com/tiborvass/replace v0.0.0-20250708165616-d642c0f9c3ff/go.mod h1:9+jQ4zDLeiANhfwMbvl6qKw/sW+aN1m6AjhxHZKN40s=
7278
github.com/vektah/gqlparser/v2 v2.5.28 h1:bIulcl3LF69ba6EiZVGD88y4MkM+Jxrf3P2MX8xLRkY=
7379
github.com/vektah/gqlparser/v2 v2.5.28/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
7480
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
@@ -109,17 +115,25 @@ go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz
109115
go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo=
110116
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
111117
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
118+
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
119+
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
112120
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
113121
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
122+
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
114123
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
115124
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
125+
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
116126
golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
117127
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
118128
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
119129
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
120130
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
131+
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
132+
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
121133
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
122134
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
135+
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
136+
golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
123137
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY=
124138
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc=
125139
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
@@ -136,3 +150,5 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
136150
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
137151
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
138152
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
153+
gotest.tools/v3 v3.0.2 h1:kG1BFyqVHuQoVQiR1bWGnfz/fmHvvuiSPIV7rvl360E=
154+
gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=

mcpserver/tools.go

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -761,30 +761,25 @@ var EnvironmentFileEditTool = &Tool{
761761
return mcp.NewToolResultErrorFromErr("unable to open the environment", err), nil
762762
}
763763

764-
targetFile, err := request.RequireString("target_file")
765-
if err != nil {
766-
return nil, err
767-
}
764+
dag, _ := ctx.Value(daggerClientKey{}).(*dagger.Client)
768765

769-
args := request.GetArguments()
770-
v, ok := args["edits"]
771-
if !ok {
772-
return nil, fmt.Errorf("could not find `edits` argument")
766+
var args struct {
767+
TargetFile string
768+
Edits []environment.FileEdit
773769
}
774-
edits, ok := v.([]string)
775-
if !ok {
776-
return nil, fmt.Errorf("`edits` argument is expected to be a []string")
770+
if err := request.BindArguments(&args); err != nil {
771+
return nil, fmt.Errorf("could not bind arguments")
777772
}
778773

779-
if err := env.FileEdit(ctx, targetFile, edits); err != nil {
774+
if err := env.FileEdit(ctx, dag, args.TargetFile, args.Edits); err != nil {
780775
return mcp.NewToolResultErrorFromErr("failed to edit file", err), nil
781776
}
782777

783778
if err := repo.Update(ctx, env, request.GetString("explanation", "")); err != nil {
784779
return mcp.NewToolResultErrorFromErr("unable to update the environment", err), nil
785780
}
786781

787-
return mcp.NewToolResultText(fmt.Sprintf("file %s edited successfully and committed to container-use/ remote", targetFile)), nil
782+
return mcp.NewToolResultText(fmt.Sprintf("file %s edited successfully and committed to container-use/ remote", args.TargetFile)), nil
788783
},
789784
}
790785

0 commit comments

Comments
 (0)