├── .dagger
├── .gitattributes
├── .gitignore
├── go.mod
├── go.sum
└── main.go
├── .github
├── ISSUE_TEMPLATE
│ └── config.yml
└── workflows
│ ├── cu.yml
│ └── release.yml
├── .gitignore
├── .goreleaser.yaml
├── LICENSE
├── README.md
├── RELEASING.md
├── _assets
├── container-use.png
├── demo.gif
└── logo.png
├── cmd
└── cu
│ ├── delete.go
│ ├── list.go
│ ├── log.go
│ ├── main.go
│ ├── merge.go
│ ├── terminal.go
│ └── watch.go
├── dagger.json
├── environment
├── README.md
├── environment.go
├── filesystem.go
└── git.go
├── examples
├── hello_world.md
├── parallel.md
└── security.md
├── go.mod
├── go.sum
├── install.sh
├── mcpserver
└── tools.go
├── rules
├── agent.md
├── cursor.mdc
└── rules.go
└── uninstall.sh
/.dagger/.gitattributes:
--------------------------------------------------------------------------------
1 | /dagger.gen.go linguist-generated
2 | /internal/dagger/** linguist-generated
3 | /internal/querybuilder/** linguist-generated
4 | /internal/telemetry/** linguist-generated
5 |
--------------------------------------------------------------------------------
/.dagger/.gitignore:
--------------------------------------------------------------------------------
1 | /dagger.gen.go
2 | /internal/dagger
3 | /internal/querybuilder
4 | /internal/telemetry
5 |
--------------------------------------------------------------------------------
/.dagger/go.mod:
--------------------------------------------------------------------------------
1 | module dagger/container-use
2 |
3 | go 1.24.3
4 |
5 | require (
6 | github.com/99designs/gqlgen v0.17.73
7 | github.com/Khan/genqlient v0.8.1
8 | github.com/vektah/gqlparser/v2 v2.5.27
9 | go.opentelemetry.io/otel v1.34.0
10 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0
11 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0
12 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0
13 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0
14 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0
15 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0
16 | go.opentelemetry.io/otel/log v0.8.0
17 | go.opentelemetry.io/otel/metric v1.34.0
18 | go.opentelemetry.io/otel/sdk v1.34.0
19 | go.opentelemetry.io/otel/sdk/log v0.8.0
20 | go.opentelemetry.io/otel/sdk/metric v1.34.0
21 | go.opentelemetry.io/otel/trace v1.34.0
22 | go.opentelemetry.io/proto/otlp v1.3.1
23 | golang.org/x/sync v0.14.0
24 | google.golang.org/grpc v1.72.1
25 | )
26 |
27 | require (
28 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect
29 | github.com/go-logr/logr v1.4.2 // indirect
30 | github.com/go-logr/stdr v1.2.2 // indirect
31 | github.com/google/uuid v1.6.0 // indirect
32 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 // indirect
33 | github.com/sosodev/duration v1.3.1 // indirect
34 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect
35 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 // indirect
36 | golang.org/x/net v0.40.0 // indirect
37 | golang.org/x/sys v0.33.0 // indirect
38 | golang.org/x/text v0.25.0 // indirect
39 | google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
40 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
41 | google.golang.org/protobuf v1.36.6 // indirect
42 | )
43 |
44 | replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0
45 |
46 | replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0
47 |
48 | replace go.opentelemetry.io/otel/log => go.opentelemetry.io/otel/log v0.8.0
49 |
50 | replace go.opentelemetry.io/otel/sdk/log => go.opentelemetry.io/otel/sdk/log v0.8.0
51 |
--------------------------------------------------------------------------------
/.dagger/go.sum:
--------------------------------------------------------------------------------
1 | github.com/99designs/gqlgen v0.17.73 h1:A3Ki+rHWqKbAOlg5fxiZBnz6OjW3nwupDHEG15gEsrg=
2 | github.com/99designs/gqlgen v0.17.73/go.mod h1:2RyGWjy2k7W9jxrs8MOQthXGkD3L3oGr0jXW3Pu8lGg=
3 | github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs=
4 | github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU=
5 | github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
6 | github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
7 | github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
8 | github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
9 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
11 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
12 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
13 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
14 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
15 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
16 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
17 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
18 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
19 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
20 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
21 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
22 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 h1:ad0vkEBuk23VJzZR9nkLVG0YAoN9coASF1GusYX6AlU=
23 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0/go.mod h1:igFoXX2ELCW06bol23DWPB5BEWfZISOzSP5K2sbLea0=
24 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
25 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
26 | github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
27 | github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
28 | github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
29 | github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
30 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
31 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
32 | github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
33 | github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
34 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
35 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
36 | go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
37 | go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
38 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 h1:WzNab7hOOLzdDF/EoWCt4glhrbMPVMOO5JYTmpz36Ls=
39 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0/go.mod h1:hKvJwTzJdp90Vh7p6q/9PAOd55dI6WA6sWj62a/JvSs=
40 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 h1:S+LdBGiQXtJdowoJoQPEtI52syEP/JYBUpjO49EQhV8=
41 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0/go.mod h1:5KXybFvPGds3QinJWQT7pmXf+TN5YIa7CNYObWRkj50=
42 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 h1:j7ZSD+5yn+lo3sGV69nW04rRR0jhYnBwjuX3r0HvnK0=
43 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0/go.mod h1:WXbYJTUaZXAbYd8lbgGuvih0yuCfOFC5RJoYnoLcGz8=
44 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 h1:t/Qur3vKSkUCcDVaSumWF2PKHt85pc7fRvFuoVT8qFU=
45 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0/go.mod h1:Rl61tySSdcOJWoEgYZVtmnKdA0GeKrSqkHC1t+91CH8=
46 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 h1:IJFEoHiytixx8cMiVAO+GmHR6Frwu+u5Ur8njpFO6Ac=
47 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0/go.mod h1:3rHrKNtLIoS0oZwkY2vxi+oJcwFRWdtUyRII+so45p8=
48 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0 h1:9kV11HXBHZAvuPUZxmMWrH8hZn/6UnHX4K0mu36vNsU=
49 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0/go.mod h1:JyA0FHXe22E1NeNiHmVp7kFHglnexDQ7uRWDiiJ1hKQ=
50 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 h1:cMyu9O88joYEaI47CnQkxO1XZdpoTF9fEnW2duIddhw=
51 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0/go.mod h1:6Am3rn7P9TVVeXYG+wtcGE7IE1tsQ+bP3AuWcKt/gOI=
52 | go.opentelemetry.io/otel/log v0.8.0 h1:egZ8vV5atrUWUbnSsHn6vB8R21G2wrKqNiDt3iWertk=
53 | go.opentelemetry.io/otel/log v0.8.0/go.mod h1:M9qvDdUTRCopJcGRKg57+JSQ9LgLBrwwfC32epk5NX8=
54 | go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
55 | go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
56 | go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
57 | go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
58 | go.opentelemetry.io/otel/sdk/log v0.8.0 h1:zg7GUYXqxk1jnGF/dTdLPrK06xJdrXgqgFLnI4Crxvs=
59 | go.opentelemetry.io/otel/sdk/log v0.8.0/go.mod h1:50iXr0UVwQrYS45KbruFrEt4LvAdCaWWgIrsN3ZQggo=
60 | go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
61 | go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
62 | go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
63 | go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
64 | go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
65 | go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
66 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
67 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
68 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
69 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
70 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
71 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
72 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
73 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
74 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
75 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
76 | google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
77 | google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
78 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4=
79 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ=
80 | google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA=
81 | google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
82 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
83 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
84 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
85 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
86 |
--------------------------------------------------------------------------------
/.dagger/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "dagger/container-use/internal/dagger"
6 | )
7 |
8 | type ContainerUse struct {
9 | Source *dagger.Directory
10 | }
11 |
12 | // dagger module for building container-use
13 | func New(
14 | //+defaultPath="/"
15 | source *dagger.Directory,
16 | ) *ContainerUse {
17 | return &ContainerUse{
18 | Source: source,
19 | }
20 | }
21 |
22 | // Build creates a binary for the current platform
23 | func (m *ContainerUse) Build(ctx context.Context,
24 | //+optional
25 | platform dagger.Platform,
26 | ) *dagger.File {
27 | return dag.Go(m.Source).Binary("./cmd/cu", dagger.GoBinaryOpts{
28 | Platform: platform,
29 | })
30 | }
31 |
32 | // BuildMultiPlatform builds binaries for multiple platforms using GoReleaser
33 | func (m *ContainerUse) BuildMultiPlatform(ctx context.Context) *dagger.Directory {
34 | return dag.Goreleaser(m.Source).Build().WithSnapshot().All()
35 | }
36 |
37 | // Release creates a release using GoReleaser
38 | func (m *ContainerUse) Release(ctx context.Context,
39 | // Version tag for the release
40 | version string,
41 | // GitHub token for authentication
42 | githubToken *dagger.Secret,
43 | ) (string, error) {
44 | return dag.Goreleaser(m.Source).
45 | WithSecretVariable("GITHUB_TOKEN", githubToken).
46 | Release().
47 | Run(ctx)
48 | }
49 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: true
2 | contact_links: []
3 |
--------------------------------------------------------------------------------
/.github/workflows/cu.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 | branches: [main]
8 | workflow_dispatch:
9 |
10 | jobs:
11 | build:
12 | name: Build
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Checkout
16 | uses: actions/checkout@v4
17 |
18 | - name: Build with Dagger
19 | uses: dagger/dagger-for-github@8.0.0
20 | with:
21 | version: "latest"
22 | verb: call
23 | args: build -o cu
24 |
25 | - name: Verify binary
26 | run: |
27 | if [ ! -f cu ]; then
28 | echo "Binary 'cu' was not created"
29 | exit 1
30 | fi
31 | echo "Binary created successfully"
32 | ls -la cu
33 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - "v*"
7 |
8 | jobs:
9 | release:
10 | name: Release
11 | runs-on: ubuntu-latest
12 | permissions:
13 | contents: write
14 | steps:
15 | - name: Checkout
16 | uses: actions/checkout@v4
17 | with:
18 | fetch-depth: 0
19 |
20 | - name: Release with Dagger
21 | uses: dagger/dagger-for-github@8.0.0
22 | with:
23 | version: "latest"
24 | verb: call
25 | args: release --version "${GITHUB_REF#refs/tags/}" --github-token env:GITHUB_TOKEN
26 | env:
27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
28 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /cu
2 |
--------------------------------------------------------------------------------
/.goreleaser.yaml:
--------------------------------------------------------------------------------
1 | # GoReleaser configuration for container-use
2 | version: 2
3 |
4 | project_name: container-use
5 |
6 | before:
7 | hooks:
8 | - go mod tidy
9 |
10 | builds:
11 | - id: cu
12 | binary: cu
13 | main: ./cmd/cu
14 | env:
15 | - CGO_ENABLED=0
16 | goos:
17 | - linux
18 | - darwin
19 | goarch:
20 | - amd64
21 | - arm64
22 |
23 | ldflags:
24 | - -s -w
25 | - -X main.version={{.Version}}
26 | - -X main.commit={{.Commit}}
27 | - -X main.date={{.Date}}
28 |
29 | archives:
30 | - id: cu-archive
31 | ids:
32 | - cu
33 | name_template: "{{ .ProjectName }}_{{ .Tag }}_{{ .Os }}_{{ .Arch }}"
34 | files:
35 | - README.md
36 | - LICENSE
37 |
38 | checksum:
39 | name_template: "checksums.txt"
40 |
41 | changelog:
42 | sort: asc
43 | use: github
44 | filters:
45 | exclude:
46 | - "^docs:"
47 | - "^test:"
48 | - "^ci:"
49 | - "^chore:"
50 | - "Merge pull request"
51 | - "Merge branch"
52 |
53 | release:
54 | draft: true
55 | prerelease: auto
56 | mode: replace
57 | header: |
58 | ## container-use {{ .Tag }}
59 |
60 | Download the pre-compiled binaries from the assets below.
61 | footer: |
62 | **Full Changelog**: https://github.com/dagger/container-use/compare/{{ .PreviousTag }}...{{ .Tag }}
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | Apache License
3 | Version 2.0, January 2004
4 | http://www.apache.org/licenses/
5 |
6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7 |
8 | 1. Definitions.
9 |
10 | "License" shall mean the terms and conditions for use, reproduction,
11 | and distribution as defined by Sections 1 through 9 of this document.
12 |
13 | "Licensor" shall mean the copyright owner or entity authorized by
14 | the copyright owner that is granting the License.
15 |
16 | "Legal Entity" shall mean the union of the acting entity and all
17 | other entities that control, are controlled by, or are under common
18 | control with that entity. For the purposes of this definition,
19 | "control" means (i) the power, direct or indirect, to cause the
20 | direction or management of such entity, whether by contract or
21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 | outstanding shares, or (iii) beneficial ownership of such entity.
23 |
24 | "You" (or "Your") shall mean an individual or Legal Entity
25 | exercising permissions granted by this License.
26 |
27 | "Source" form shall mean the preferred form for making modifications,
28 | including but not limited to software source code, documentation
29 | source, and configuration files.
30 |
31 | "Object" form shall mean any form resulting from mechanical
32 | transformation or translation of a Source form, including but
33 | not limited to compiled object code, generated documentation,
34 | and conversions to other media types.
35 |
36 | "Work" shall mean the work of authorship, whether in Source or
37 | Object form, made available under the License, as indicated by a
38 | copyright notice that is included in or attached to the work
39 | (an example is provided in the Appendix below).
40 |
41 | "Derivative Works" shall mean any work, whether in Source or Object
42 | form, that is based on (or derived from) the Work and for which the
43 | editorial revisions, annotations, elaborations, or other modifications
44 | represent, as a whole, an original work of authorship. For the purposes
45 | of this License, Derivative Works shall not include works that remain
46 | separable from, or merely link (or bind by name) to the interfaces of,
47 | the Work and Derivative Works thereof.
48 |
49 | "Contribution" shall mean any work of authorship, including
50 | the original version of the Work and any modifications or additions
51 | to that Work or Derivative Works thereof, that is intentionally
52 | submitted to Licensor for inclusion in the Work by the copyright owner
53 | or by an individual or Legal Entity authorized to submit on behalf of
54 | the copyright owner. For the purposes of this definition, "submitted"
55 | means any form of electronic, verbal, or written communication sent
56 | to the Licensor or its representatives, including but not limited to
57 | communication on electronic mailing lists, source code control systems,
58 | and issue tracking systems that are managed by, or on behalf of, the
59 | Licensor for the purpose of discussing and improving the Work, but
60 | excluding communication that is conspicuously marked or otherwise
61 | designated in writing by the copyright owner as "Not a Contribution."
62 |
63 | "Contributor" shall mean Licensor and any individual or Legal Entity
64 | on behalf of whom a Contribution has been received by Licensor and
65 | subsequently incorporated within the Work.
66 |
67 | 2. Grant of Copyright License. Subject to the terms and conditions of
68 | this License, each Contributor hereby grants to You a perpetual,
69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 | copyright license to reproduce, prepare Derivative Works of,
71 | publicly display, publicly perform, sublicense, and distribute the
72 | Work and such Derivative Works in Source or Object form.
73 |
74 | 3. Grant of Patent License. Subject to the terms and conditions of
75 | this License, each Contributor hereby grants to You a perpetual,
76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 | (except as stated in this section) patent license to make, have made,
78 | use, offer to sell, sell, import, and otherwise transfer the Work,
79 | where such license applies only to those patent claims licensable
80 | by such Contributor that are necessarily infringed by their
81 | Contribution(s) alone or by combination of their Contribution(s)
82 | with the Work to which such Contribution(s) was submitted. If You
83 | institute patent litigation against any entity (including a
84 | cross-claim or counterclaim in a lawsuit) alleging that the Work
85 | or a Contribution incorporated within the Work constitutes direct
86 | or contributory patent infringement, then any patent licenses
87 | granted to You under this License for that Work shall terminate
88 | as of the date such litigation is filed.
89 |
90 | 4. Redistribution. You may reproduce and distribute copies of the
91 | Work or Derivative Works thereof in any medium, with or without
92 | modifications, and in Source or Object form, provided that You
93 | meet the following conditions:
94 |
95 | (a) You must give any other recipients of the Work or
96 | Derivative Works a copy of this License; and
97 |
98 | (b) You must cause any modified files to carry prominent notices
99 | stating that You changed the files; and
100 |
101 | (c) You must retain, in the Source form of any Derivative Works
102 | that You distribute, all copyright, patent, trademark, and
103 | attribution notices from the Source form of the Work,
104 | excluding those notices that do not pertain to any part of
105 | the Derivative Works; and
106 |
107 | (d) If the Work includes a "NOTICE" text file as part of its
108 | distribution, then any Derivative Works that You distribute must
109 | include a readable copy of the attribution notices contained
110 | within such NOTICE file, excluding those notices that do not
111 | pertain to any part of the Derivative Works, in at least one
112 | of the following places: within a NOTICE text file distributed
113 | as part of the Derivative Works; within the Source form or
114 | documentation, if provided along with the Derivative Works; or,
115 | within a display generated by the Derivative Works, if and
116 | wherever such third-party notices normally appear. The contents
117 | of the NOTICE file are for informational purposes only and
118 | do not modify the License. You may add Your own attribution
119 | notices within Derivative Works that You distribute, alongside
120 | or as an addendum to the NOTICE text from the Work, provided
121 | that such additional attribution notices cannot be construed
122 | as modifying the License.
123 |
124 | You may add Your own copyright statement to Your modifications and
125 | may provide additional or different license terms and conditions
126 | for use, reproduction, or distribution of Your modifications, or
127 | for any such Derivative Works as a whole, provided Your use,
128 | reproduction, and distribution of the Work otherwise complies with
129 | the conditions stated in this License.
130 |
131 | 5. Submission of Contributions. Unless You explicitly state otherwise,
132 | any Contribution intentionally submitted for inclusion in the Work
133 | by You to the Licensor shall be under the terms and conditions of
134 | this License, without any additional terms or conditions.
135 | Notwithstanding the above, nothing herein shall supersede or modify
136 | the terms of any separate license agreement you may have executed
137 | with Licensor regarding such Contributions.
138 |
139 | 6. Trademarks. This License does not grant permission to use the trade
140 | names, trademarks, service marks, or product names of the Licensor,
141 | except as required for reasonable and customary use in describing the
142 | origin of the Work and reproducing the content of the NOTICE file.
143 |
144 | 7. Disclaimer of Warranty. Unless required by applicable law or
145 | agreed to in writing, Licensor provides the Work (and each
146 | Contributor provides its Contributions) on an "AS IS" BASIS,
147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 | implied, including, without limitation, any warranties or conditions
149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 | PARTICULAR PURPOSE. You are solely responsible for determining the
151 | appropriateness of using or redistributing the Work and assume any
152 | risks associated with Your exercise of permissions under this License.
153 |
154 | 8. Limitation of Liability. In no event and under no legal theory,
155 | whether in tort (including negligence), contract, or otherwise,
156 | unless required by applicable law (such as deliberate and grossly
157 | negligent acts) or agreed to in writing, shall any Contributor be
158 | liable to You for damages, including any direct, indirect, special,
159 | incidental, or consequential damages of any character arising as a
160 | result of this License or out of the use or inability to use the
161 | Work (including but not limited to damages for loss of goodwill,
162 | work stoppage, computer failure or malfunction, or any and all
163 | other commercial damages or losses), even if such Contributor
164 | has been advised of the possibility of such damages.
165 |
166 | 9. Accepting Warranty or Additional Liability. While redistributing
167 | the Work or Derivative Works thereof, You may choose to offer,
168 | and charge a fee for, acceptance of support, warranty, indemnity,
169 | or other liability obligations and/or rights consistent with this
170 | License. However, in accepting such obligations, You may act only
171 | on Your own behalf and on Your sole responsibility, not on behalf
172 | of any other Contributor, and only if You agree to indemnify,
173 | defend, and hold each Contributor harmless for any liability
174 | incurred by, or claims asserted against, such Contributor by reason
175 | of your accepting any such warranty or additional liability.
176 |
177 | END OF TERMS AND CONDITIONS
178 |
179 | Copyright 2022 Dagger, Inc.
180 |
181 | Licensed under the Apache License, Version 2.0 (the "License");
182 | you may not use this file except in compliance with the License.
183 | You may obtain a copy of the License at
184 |
185 | http://www.apache.org/licenses/LICENSE-2.0
186 |
187 | Unless required by applicable law or agreed to in writing, software
188 | distributed under the License is distributed on an "AS IS" BASIS,
189 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
190 | See the License for the specific language governing permissions and
191 | limitations under the License.
192 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |
container-use
4 |
Containerized environments for coding agents. (📦🤖) (📦🤖) (📦🤖)
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | **Container Use** lets each of your coding agents have their own containerized environment. Go from babysitting one agent at a time to enabling multiple agents to work safely and independently with your preferred stack.
17 |
18 |
19 |
20 |
21 |
22 | It's an open-source MCP server that works as a CLI tool with Claude Code, Cursor, and other MCP-compatible agents.
23 |
24 | * 📦 **Isolated Environments**: Each agent gets a fresh container in its own git branch - run multiple agents without conflicts, experiment safely, discard failures instantly.
25 | * 👀 **Real-time Visibility**: See complete command history and logs of what agents actually did, not just what they claim.
26 | * 🚁 **Direct Intervention**: Drop into any agent's terminal to see their state and take control when they get stuck.
27 | * 🎮 **Environment Control**: Standard git workflow - just `git checkout ` to review any agent's work.
28 | * 🌎 **Universal Compatibility**: Works with any agent, model, or infrastructure - no vendor lock-in.
29 |
30 | ---
31 |
32 | 🦺 This project is in early development and actively evolving. Expect rough edges, breaking changes, and incomplete documentation. But also expect rapid iteration and responsiveness to feedback.
33 |
34 | ---
35 |
36 | ## Install
37 |
38 | ```sh
39 | curl -fsSL https://raw.githubusercontent.com/dagger/container-use/main/install.sh | bash
40 | ```
41 |
42 | This will check for Docker & Git (required), detect your platform, and install the latest `cu` binary to your `$PATH`.
43 |
44 | ## Building
45 |
46 | To build the `cu` binary without installing it to your `$PATH`, you can use either Dagger or Go directly:
47 |
48 | ### Using Go
49 |
50 | ```sh
51 | go build -o cu ./cmd/cu
52 | ```
53 |
54 | ### Using Dagger
55 |
56 | ```sh
57 | dagger call build --platform=current export --path ./cu
58 | ```
59 |
60 | ## Integrate Agents
61 |
62 | Enabling `container-use` requires 2 steps:
63 |
64 | 1. Adding an MCP configuration for `container-use` corresponding to the repository.
65 | 2. (Optional) Adding a rule so the agent uses containarized environments.
66 |
67 | ### [Claude Code](https://docs.anthropic.com/en/docs/claude-code/tutorials#set-up-model-context-protocol-mcp)
68 |
69 | Add the container-use MCP:
70 |
71 | ```sh
72 | cd /path/to/repository
73 | npx @anthropic-ai/claude-code mcp add container-use -- stdio
74 | ```
75 |
76 | Save the CLAUDE.md file at the root of the repository. Alternatively, merge the instructions into your own CLAUDE.md.
77 |
78 | ```sh
79 | curl https://raw.githubusercontent.com/dagger/container-use/main/rules/agent.md >> CLAUDE.md
80 | ```
81 |
82 | ### [goose](https://block.github.io/goose/docs/getting-started/using-extensions#mcp-servers)
83 |
84 | Add this to `~/.config/goose/config.yaml`:
85 |
86 | ```yaml
87 | extensions:
88 | container-use:
89 | name: container-use
90 | type: stdio
91 | enabled: true
92 | cmd: cu
93 | args:
94 | - stdio
95 | envs: {}
96 | ```
97 |
98 | ### [Cursor](https://docs.cursor.com/context/model-context-protocol)
99 |
100 | ```sh
101 | curl --create-dirs -o .cursor/rules/container-use.mdc https://raw.githubusercontent.com/dagger/container-use/main/rules/cursor.mdc
102 | ```
103 |
104 | ### [VSCode](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) / [GitHub Copilot](https://docs.github.com/en/copilot/customizing-copilot/extending-copilot-chat-with-mcp)
105 |
106 | The result of the instructions above will be to update your VSCode settings with something that looks like this:
107 |
108 | ```json
109 | "mcp": {
110 | "servers": {
111 | "container-use": {
112 | "type": "stdio",
113 | "command": "cu",
114 | "args": [
115 | "stdio"
116 | ]
117 | }
118 | }
119 | }
120 | ```
121 |
122 | Once the MCP server is running, you can optionally) update the instructions for copilot using the following:
123 |
124 | ```sh
125 | curl --create-dirs -o .github/copilot-instructions.md https://raw.githubusercontent.com/dagger/container-use/main/rules/agent.md
126 | ```
127 |
128 | ### [Kilo Code](https://kilocode.ai/docs/features/mcp/using-mcp-in-kilo-code)
129 |
130 | `Kilo Code` allows setting MCP servers at the global or project level.
131 |
132 | ```json
133 | {
134 | "mcpServers": {
135 | "container-use": {
136 | "command": "replace with pathname of cu",
137 | "args": [
138 | "stdio"
139 | ],
140 | "env": {},
141 | "alwaysAllow": [],
142 | "disabled": false
143 | }
144 | }
145 | }
146 | ```
147 |
148 | ## Examples
149 |
150 | | Example | Description |
151 | |---------|-------------|
152 | | [hello_world.md](examples/hello_world.md) | Creates a simple app and runs it, accessible via localhost HTTP URL |
153 | | [parallel.md](examples/parallel.md) | Creates and serves two variations of a hello world app (Flask and FastAPI) on different URLs |
154 | | [security.md](examples/security.md) | Security scanning example that checks for updates/vulnerabilities in the repository, applies updates, verifies builds still work, and generates patch file |
155 |
156 | ### Run with [Claude Code](https://www.anthropic.com/claude-code)
157 |
158 | ```console
159 | cat ./examples/hello_world.md | claude --dangerously-skip-permissions
160 | ```
161 |
162 | ### Run with [goose](https://block.github.io/goose/)
163 |
164 | ```console
165 | goose run -i ./examples/hello_world.md -s
166 | ```
167 |
168 | ### Run with [Kilo Code](https://kilocode.ai/) in `vscode`
169 |
170 | Prompt as in `parallel.md` but add a sentence 'use container-use mcp'
171 |
172 | ## Watch your agents work
173 |
174 | Your agents will automatically commit to a container-use remote on your local filesystem. You can watch the progress of your agents in real time by running:
175 |
176 | ```console
177 | cu watch
178 | ```
179 |
180 | ## How it Works
181 |
182 | container-use is an Model Context Protocol server that provides Environments to an agent. Environments are an abstraction over containers and git branches powered by dagger and git worktrees. For more information, see [environment/README.md](environment/README.md).
183 |
--------------------------------------------------------------------------------
/RELEASING.md:
--------------------------------------------------------------------------------
1 | # Releasing
2 |
3 | ## Steps
4 |
5 | 1. **Fetch the latest main branch**
6 | ```sh
7 | git checkout main
8 | git pull origin main
9 | ```
10 |
11 | 2. **Tag the release**
12 | ```sh
13 | git tag v1.2.3
14 | ```
15 |
16 | 3. **Push the tag**
17 | ```sh
18 | git push origin v1.2.3
19 | ```
20 |
21 | 4. **Check the draft release**
22 | - Monitor the [release workflow](https://github.com/dagger/container-use/actions/workflows/release.yml) for progress and errors
23 | - Go to [GitHub Releases](https://github.com/dagger/container-use/releases)
24 | - Review the auto-generated draft release
25 | - Verify binaries and checksums are attached
26 |
27 | 5. **Publish the release**
28 | - Edit the draft release if needed
29 | - Click "Publish release"
30 |
31 | The Dagger CI automatically handles building binaries and creating the draft release when tags are pushed.
32 |
--------------------------------------------------------------------------------
/_assets/container-use.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dagger/container-use/fa2dca56c2b235e633c4d831dd64d6cb325ddda4/_assets/container-use.png
--------------------------------------------------------------------------------
/_assets/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dagger/container-use/fa2dca56c2b235e633c4d831dd64d6cb325ddda4/_assets/demo.gif
--------------------------------------------------------------------------------
/_assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dagger/container-use/fa2dca56c2b235e633c4d831dd64d6cb325ddda4/_assets/logo.png
--------------------------------------------------------------------------------
/cmd/cu/delete.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "os"
6 |
7 | "dagger.io/dagger"
8 | "github.com/dagger/container-use/environment"
9 | "github.com/spf13/cobra"
10 | )
11 |
12 | var deleteCmd = &cobra.Command{
13 | Use: "delete ",
14 | Short: "Delete an environment",
15 | Long: `Delete an environment and its associated resources.`,
16 | Args: cobra.ExactArgs(1),
17 | RunE: func(cmd *cobra.Command, args []string) error {
18 | ctx := cmd.Context()
19 | envName := args[0]
20 |
21 | dag, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stderr))
22 | if err != nil {
23 | return fmt.Errorf("failed to connect to dagger: %w", err)
24 | }
25 | defer dag.Close()
26 | environment.Initialize(dag)
27 |
28 | env := environment.Get(envName)
29 | if env == nil {
30 | // Try to open if not in memory
31 | var openErr error
32 | env, openErr = environment.Open(ctx, "delete environment", ".", envName)
33 | if openErr != nil {
34 | return fmt.Errorf("environment '%s' not found: %w", envName, openErr)
35 | }
36 | }
37 |
38 | if err := env.Delete(ctx); err != nil {
39 | return fmt.Errorf("failed to delete environment: %w", err)
40 | }
41 |
42 | fmt.Printf("Environment '%s' deleted successfully.\n", envName)
43 | fmt.Println("To view this change, use: git checkout ")
44 | return nil
45 | },
46 | }
47 |
48 | func init() {
49 | rootCmd.AddCommand(deleteCmd)
50 | }
51 |
--------------------------------------------------------------------------------
/cmd/cu/list.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "os/exec"
7 |
8 | "github.com/spf13/cobra"
9 | )
10 |
11 | var listCmd = &cobra.Command{
12 | Use: "list",
13 | Short: "List environments",
14 | Long: `List environments filtering the git remotes`,
15 | RunE: func(app *cobra.Command, _ []string) error {
16 | // Check if we're in a git repository
17 | checkCmd := exec.CommandContext(app.Context(), "git", "rev-parse", "--is-inside-work-tree")
18 | if err := checkCmd.Run(); err != nil {
19 | return fmt.Errorf("cu list only works within git repository, no repo found (or any of the parent directories): .git")
20 | }
21 |
22 | cmd := exec.CommandContext(app.Context(), "bash", "-c", "git branch -r | grep 'container-use/.*/' | cut -d/ -f2-")
23 | cmd.Stdout = os.Stdout
24 | cmd.Stderr = os.Stderr
25 | cmd.Stdin = os.Stdin
26 |
27 | return cmd.Run()
28 | },
29 | }
30 |
31 | func init() {
32 | rootCmd.AddCommand(listCmd)
33 | }
34 |
--------------------------------------------------------------------------------
/cmd/cu/log.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "io"
6 | "log/slog"
7 | "os"
8 | "time"
9 | )
10 |
11 | var (
12 | logWriter = io.Discard
13 | )
14 |
15 | func parseLogLevel(levelStr string) slog.Level {
16 | switch levelStr {
17 | case "debug", "DEBUG":
18 | return slog.LevelDebug
19 | case "info", "INFO":
20 | return slog.LevelInfo
21 | case "warn", "WARN", "warning", "WARNING":
22 | return slog.LevelWarn
23 | case "error", "ERROR":
24 | return slog.LevelError
25 | default:
26 | return slog.LevelInfo
27 | }
28 | }
29 |
30 | func setupLogger() error {
31 | var writers []io.Writer
32 |
33 | logFile := "/tmp/cu.debug.stderr.log"
34 | if v, ok := os.LookupEnv("CU_STDERR_FILE"); ok {
35 | logFile = v
36 | }
37 |
38 | file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
39 | if err != nil {
40 | return fmt.Errorf("failed to open log file %s: %w", logFile, err)
41 | }
42 | writers = append(writers, file)
43 |
44 | if len(writers) == 0 {
45 | fmt.Fprintf(os.Stderr, "%s Logging disabled. Set CU_STDERR_FILE and CU_LOG_LEVEL environment variables\n", time.Now().Format(time.DateTime))
46 | }
47 |
48 | logLevel := parseLogLevel(os.Getenv("CU_LOG_LEVEL"))
49 | logWriter = io.MultiWriter(writers...)
50 | handler := slog.NewTextHandler(logWriter, &slog.HandlerOptions{
51 | Level: logLevel,
52 | })
53 | slog.SetDefault(slog.New(handler))
54 |
55 | return nil
56 | }
57 |
--------------------------------------------------------------------------------
/cmd/cu/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | _ "embed"
6 | "fmt"
7 | "io"
8 | "log/slog"
9 | "os"
10 | "os/signal"
11 | "runtime"
12 | "syscall"
13 |
14 | "dagger.io/dagger"
15 | "github.com/dagger/container-use/environment"
16 | "github.com/dagger/container-use/mcpserver"
17 | "github.com/spf13/cobra"
18 | )
19 |
20 | var dag *dagger.Client
21 |
22 | func dumpStacks() {
23 | buf := make([]byte, 1<<20) // 1MB buffer
24 | n := runtime.Stack(buf, true)
25 | io.MultiWriter(logWriter, os.Stderr).Write(buf[:n])
26 | }
27 |
28 | var (
29 | rootCmd = &cobra.Command{
30 | Use: "cu",
31 | Short: "Container Use",
32 | Long: `MCP server to add container superpowers to your AI agent.`,
33 | }
34 |
35 | stdioCmd = &cobra.Command{
36 | Use: "stdio",
37 | Short: "Start stdio server",
38 | Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`,
39 | RunE: func(app *cobra.Command, _ []string) error {
40 | ctx := app.Context()
41 |
42 | slog.Info("connecting to dagger")
43 |
44 | var err error
45 | dag, err = dagger.Connect(ctx, dagger.WithLogOutput(logWriter))
46 | if err != nil {
47 | slog.Error("Error starting dagger", "error", err)
48 | os.Exit(1)
49 | }
50 | defer dag.Close()
51 |
52 | environment.Initialize(dag)
53 | return mcpserver.RunStdioServer(ctx)
54 | },
55 | }
56 | )
57 |
58 | func init() {
59 | rootCmd.AddCommand(
60 | stdioCmd,
61 | terminalCmd,
62 | )
63 | }
64 |
65 | func handleSIGUSR(sigusrCh <-chan os.Signal) {
66 | for sig := range sigusrCh {
67 | if sig == syscall.SIGUSR1 {
68 | dumpStacks()
69 | }
70 | }
71 | }
72 |
73 | func main() {
74 | sigusrCh := make(chan os.Signal, 1)
75 | signal.Notify(sigusrCh, syscall.SIGUSR1)
76 |
77 | go handleSIGUSR(sigusrCh)
78 |
79 | ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
80 | defer stop()
81 |
82 | if err := setupLogger(); err != nil {
83 | fmt.Fprintf(os.Stderr, "Failed to setup logger: %v\n", err)
84 | os.Exit(1)
85 | }
86 |
87 | if err := rootCmd.ExecuteContext(ctx); err != nil {
88 | fmt.Fprintf(os.Stderr, "%v\n", err)
89 | os.Exit(1)
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/cmd/cu/merge.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "os/exec"
7 | "strings"
8 |
9 | "github.com/spf13/cobra"
10 | )
11 |
12 | var mergeCmd = &cobra.Command{
13 | Use: "merge ",
14 | Short: "Merges an environment into the current git branch",
15 | Args: cobra.ExactArgs(1),
16 | RunE: func(app *cobra.Command, args []string) error {
17 | env := args[0]
18 | // prevent accidental single quotes to mess up command
19 | env = strings.Trim(env, "'")
20 | cmd := exec.CommandContext(app.Context(), "bash", "-c", fmt.Sprintf("git stash --include-untracked -q && git merge -m 'Merge environment %s' -- %q && ( git stash pop -q 2>/dev/null )", env, "container-use/"+env))
21 | cmd.Stderr = os.Stderr
22 | cmd.Stdin = os.Stdin
23 |
24 | return cmd.Run()
25 | },
26 | }
27 |
28 | func init() {
29 | rootCmd.AddCommand(mergeCmd)
30 | }
31 |
--------------------------------------------------------------------------------
/cmd/cu/terminal.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "log/slog"
7 | "os"
8 | "os/exec"
9 | "syscall"
10 |
11 | "dagger.io/dagger"
12 | "github.com/dagger/container-use/environment"
13 | "github.com/spf13/cobra"
14 | )
15 |
16 | var terminalCmd = &cobra.Command{
17 | Use: "terminal ",
18 | Short: "Drop a terminal into an environment",
19 | Long: `Create a container with the same state as the agent for a given branch or commmit.`,
20 | Args: cobra.ExactArgs(1),
21 | RunE: func(app *cobra.Command, args []string) error {
22 | ctx := app.Context()
23 |
24 | // FIXME(aluzzardi): This is a hack to make sure we're wrapped in `dagger run` since `Terminal()` only works with the CLI.
25 | // If not, it will auto-wrap this command in a `dagger run`.
26 | if _, ok := os.LookupEnv("DAGGER_SESSION_TOKEN"); !ok {
27 | daggerBin, err := exec.LookPath("dagger")
28 | if err != nil {
29 | if errors.Is(err, exec.ErrNotFound) {
30 | return fmt.Errorf("dagger is not installed. Please install it from https://docs.dagger.io/install/")
31 | }
32 | return fmt.Errorf("failed to look up dagger binary: %w", err)
33 | }
34 | return syscall.Exec(daggerBin, append([]string{"dagger", "run"}, os.Args...), os.Environ())
35 | }
36 |
37 | dag, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stderr))
38 | if err != nil {
39 | slog.Error("Error starting dagger", "error", err)
40 | os.Exit(1)
41 | }
42 | defer dag.Close()
43 | environment.Initialize(dag)
44 |
45 | env, err := environment.Open(ctx, "opening terminal", ".", args[0])
46 | if err != nil {
47 | return err
48 | }
49 |
50 | return env.Terminal(ctx)
51 | },
52 | }
53 |
--------------------------------------------------------------------------------
/cmd/cu/watch.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/spf13/cobra"
7 | watch "github.com/tiborvass/go-watch"
8 | )
9 |
10 | var watchCmd = &cobra.Command{
11 | Use: "watch",
12 | Short: "Watch git log output",
13 | Long: `Watch the following git log command every second: 'git log --color=always --remotes=container-use --oneline --graph --decorate'.`,
14 | RunE: func(app *cobra.Command, _ []string) error {
15 | w := watch.Watcher{Interval: time.Second}
16 | w.Watch(app.Context(), "git", "log", "--color=always", "--remotes=container-use", "--oneline", "--graph", "--decorate")
17 | return nil
18 | },
19 | }
20 |
21 | func init() {
22 | rootCmd.AddCommand(watchCmd)
23 | }
24 |
--------------------------------------------------------------------------------
/dagger.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "container-use",
3 | "engineVersion": "v0.18.9",
4 | "sdk": {
5 | "source": "go"
6 | },
7 | "dependencies": [
8 | {
9 | "name": "go",
10 | "source": "github.com/dagger/dagger/modules/go",
11 | "pin": "c14140b30dd986d9ebff78a605841606486d2554"
12 | },
13 | {
14 | "name": "goreleaser",
15 | "source": "github.com/act3-ai/dagger/goreleaser",
16 | "pin": "f432d3878b83c6abea3d4587521bd34da3c0c936"
17 | }
18 | ],
19 | "source": ".dagger"
20 | }
21 |
--------------------------------------------------------------------------------
/environment/README.md:
--------------------------------------------------------------------------------
1 | # Environments
2 |
3 | An **environment** is an isolated, containerized development workspace that combines Docker containers with Git branches to provide agents with safe, persistent workspaces.
4 |
5 | ## What is an Environment?
6 |
7 | Each environment consists of:
8 | - **Git Branch**: Dedicated branch tracking all changes and history
9 | - **Container**: Dagger container with your code and dependencies
10 | - **History**: Versioned snapshots of container state changes appended to the branch as notes
11 | - **Configuration**: Base image, setup commands, secrets, and instructions that can be checked into the source repo.
12 |
13 | ## Key Features
14 |
15 | - **Branch-Based**: Each environment is a Git branch that syncs into the container-use/ remote
16 | - **Isolation**: Each environment runs in its own container and branch
17 | - **Persistence**: All changes automatically committed with full history
18 | - **Standard Git**:
19 | - Use `git log` to view source code history
20 | - Use `git log --notes=container-use` to view container state history
21 | - Use `git checkout env-branch` to inspect any environment's work - each env branch tracks the upstream container-use/
22 | - **State Recovery**: Container states stored in Git notes for reconstruction
23 |
24 | ## How It Works
25 |
26 | When you create an environment, container-use:
27 |
28 | 1. **Creates a new Git branch** in your source repo (e.g., `env-name/adverb-animal`)
29 | 2. **Sets up a container-use remote branch** inside `~/.config/container-use/repos/project/`
30 | 3. **Sets up a worktree copy of the branch** in `~/.config/container-use/worktrees/project/`
31 | 4. **Spins up a Dagger container** with that worktree copied into `/workdir`
32 |
33 | When an agent runs commands:
34 |
35 | 1. **Commands execute** inside the isolated container
36 | 2. **File changes get written** back to the container filesystem
37 | 3. **Everything gets committed** to the environment's Git branch automatically
38 | 4. **Container state snapshots** are stored as Git notes for later recovery
39 |
40 | Each environment is just a Git branch that your source repo tracks on the container-use/ remote. You can inspect any environment's work using standard Git commands, and the container state can always be reconstructed from an environment branch's Git history.
41 |
42 | ## Architecture
43 |
44 | ```
45 | projectName/ Source Repo container-use/ Remote
46 | ├── main ←──→ ├── main
47 | ├── feature-branch ←──→ ├── feature-branch
48 | └── env-name/adverb-animal ←──→ └── env-name/adverb-animal
49 | │
50 | │ (host filesystem implementation)
51 | ▼
52 | ~/.config/container-use/
53 | ├── repos/projectName/ (bare)
54 | └── worktrees/env-name/adverb-animal (only env branches become worktrees)
55 | ├── .git -> ../../repos/projectName/worktrees/env-name/adverb-animal
56 | └── (your code)
57 | │
58 | ▼
59 | Container
60 | └── /workdir
61 | ```
62 |
63 | The diagram shows how branches sync between your source repo and the container-use remote. Each environment branch (like `env-name/adverb-animal`) exists in both places and stays synchronized.
64 |
65 | Below the branch level, the system creates a bare Git repository and worktree in `~/.config/container-use/` - this is plumbing to make the Git operations work with minimal modifications to your source repository. The worktree contains a copy of your code that gets mounted into the Docker container at `/workdir`.
66 |
67 | So the flow is: **Branch** (the logical environment) → **Worktree** (filesystem implementation) → **Container** (where code actually runs).
68 |
69 | ## Files
70 |
71 | - `environment.go` - Core environment management
72 | - `git.go` - Worktree and Git integration
73 | - `filesystem.go` - File operations within containers
74 |
--------------------------------------------------------------------------------
/environment/environment.go:
--------------------------------------------------------------------------------
1 | package environment
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "errors"
7 | "fmt"
8 | "log/slog"
9 | "math/rand"
10 | "os"
11 | "path"
12 | "strings"
13 | "sync"
14 | "time"
15 |
16 | "dagger.io/dagger"
17 |
18 | petname "github.com/dustinkirkland/golang-petname"
19 | )
20 |
21 | var dag *dagger.Client
22 |
23 | const (
24 | defaultImage = "ubuntu:24.04"
25 | alpineImage = "alpine:3.21.3@sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c"
26 | configDir = ".container-use"
27 | instructionsFile = "AGENT.md"
28 | environmentFile = "environment.json"
29 | lockFile = "lock"
30 | )
31 |
32 | type Version int
33 |
34 | type Revision struct {
35 | Version Version `json:"version"`
36 | Name string `json:"name"`
37 | Explanation string `json:"explanation"`
38 | Output string `json:"output,omitempty"`
39 | CreatedAt time.Time `json:"created_at"`
40 | State string `json:"state"`
41 |
42 | container *dagger.Container `json:"-"`
43 | }
44 |
45 | type History []*Revision
46 |
47 | func (h History) Latest() *Revision {
48 | if len(h) == 0 {
49 | return nil
50 | }
51 | return h[len(h)-1]
52 | }
53 |
54 | func (h History) LatestVersion() Version {
55 | latest := h.Latest()
56 | if latest == nil {
57 | return 0
58 | }
59 | return latest.Version
60 | }
61 |
62 | func (h History) Get(version Version) *Revision {
63 | for _, revision := range h {
64 | if revision.Version == version {
65 | return revision
66 | }
67 | }
68 | return nil
69 | }
70 |
71 | func Initialize(client *dagger.Client) error {
72 | dag = client
73 | return nil
74 | }
75 |
76 | type Environment struct {
77 | ID string `json:"-"`
78 | Name string `json:"-"`
79 | Source string `json:"-"`
80 | Worktree string `json:"-"`
81 |
82 | Instructions string `json:"-"`
83 | Workdir string `json:"workdir"`
84 | BaseImage string `json:"base_image"`
85 | SetupCommands []string `json:"setup_commands,omitempty"`
86 | Secrets []string `json:"secrets,omitempty"`
87 |
88 | History History `json:"-"`
89 |
90 | mu sync.Mutex
91 | container *dagger.Container
92 | }
93 |
94 | func (env *Environment) save(baseDir string) error {
95 | cfg := path.Join(baseDir, configDir)
96 | if err := os.MkdirAll(cfg, 0755); err != nil {
97 | return err
98 | }
99 |
100 | if err := os.WriteFile(path.Join(cfg, instructionsFile), []byte(env.Instructions), 0644); err != nil {
101 | return err
102 | }
103 |
104 | envState, err := json.MarshalIndent(env, "", " ")
105 | if err != nil {
106 | return err
107 | }
108 |
109 | if err := os.WriteFile(path.Join(cfg, environmentFile), envState, 0644); err != nil {
110 | return err
111 | }
112 |
113 | return nil
114 | }
115 |
116 | func (env *Environment) load(baseDir string) error {
117 | cfg := path.Join(baseDir, configDir)
118 |
119 | instructions, err := os.ReadFile(path.Join(cfg, instructionsFile))
120 | if err != nil {
121 | return err
122 | }
123 | env.Instructions = string(instructions)
124 |
125 | envState, err := os.ReadFile(path.Join(cfg, environmentFile))
126 | if err != nil {
127 | return err
128 | }
129 | if err := json.Unmarshal(envState, env); err != nil {
130 | return err
131 | }
132 |
133 | return nil
134 | }
135 |
136 | func (env *Environment) isLocked(baseDir string) bool {
137 | if _, err := os.Stat(path.Join(baseDir, configDir, lockFile)); err == nil {
138 | return true
139 | }
140 | return false
141 | }
142 |
143 | func (env *Environment) apply(ctx context.Context, name, explanation, output string, newState *dagger.Container) error {
144 | if _, err := newState.Sync(ctx); err != nil {
145 | return err
146 | }
147 |
148 | env.mu.Lock()
149 | defer env.mu.Unlock()
150 | revision := &Revision{
151 | Version: env.History.LatestVersion() + 1,
152 | Name: name,
153 | Explanation: explanation,
154 | Output: output,
155 | CreatedAt: time.Now(),
156 | container: newState,
157 | }
158 | containerID, err := revision.container.ID(ctx)
159 | if err != nil {
160 | return err
161 | }
162 | revision.State = string(containerID)
163 | env.container = revision.container
164 | env.History = append(env.History, revision)
165 |
166 | return nil
167 | }
168 |
169 | var environments = map[string]*Environment{}
170 |
171 | func Create(ctx context.Context, explanation, source, name string) (*Environment, error) {
172 | env := &Environment{
173 | ID: fmt.Sprintf("%s/%s", name, petname.Generate(2, "-")),
174 | Name: name,
175 | Source: source,
176 | BaseImage: defaultImage,
177 | Instructions: "No instructions found. Please look around the filesystem and update me",
178 | Workdir: "/workdir",
179 | }
180 | if err := env.load(source); err != nil {
181 | if !errors.Is(err, os.ErrNotExist) {
182 | return nil, err
183 | }
184 | }
185 |
186 | worktreePath, err := env.InitializeWorktree(ctx, source)
187 | if err != nil {
188 | return nil, fmt.Errorf("failed intializing worktree: %w", err)
189 | }
190 | env.Worktree = worktreePath
191 |
192 | container, err := env.buildBase(ctx)
193 | if err != nil {
194 | return nil, err
195 | }
196 |
197 | slog.Info("Creating environment", "id", env.ID, "name", env.Name, "workdir", env.Workdir)
198 |
199 | if err := env.apply(ctx, "Create environment", "Create the environment", "", container); err != nil {
200 | return nil, err
201 | }
202 | environments[env.ID] = env
203 |
204 | if err := env.propagateToWorktree(ctx, "Init env "+name, explanation); err != nil {
205 | return nil, fmt.Errorf("failed to propagate to worktree: %w", err)
206 | }
207 |
208 | return env, nil
209 | }
210 |
211 | func Open(ctx context.Context, explanation, source, id string) (*Environment, error) {
212 | // FIXME(aluzzardi): DO NOT USE THIS FUNCTION. It's broken.
213 |
214 | name, _, _ := strings.Cut(id, "/")
215 | env := &Environment{
216 | Name: name,
217 | ID: id,
218 | Source: source,
219 | }
220 | worktreePath, err := env.InitializeWorktree(ctx, source)
221 | if err != nil {
222 | return nil, fmt.Errorf("failed intializing worktree: %w", err)
223 | }
224 | env.Worktree = worktreePath
225 |
226 | if err := env.load(worktreePath); err != nil {
227 | if errors.Is(err, os.ErrNotExist) {
228 | return Create(ctx, explanation, source, name)
229 | }
230 | return nil, err
231 | }
232 |
233 | container, err := env.buildBase(ctx)
234 | if err != nil {
235 | return nil, err
236 | }
237 | if err := env.apply(ctx, "Open environment", "Open the environment", "", container); err != nil {
238 | return nil, err
239 | }
240 |
241 | environments[env.ID] = env
242 |
243 | return env, nil
244 |
245 | // FIXME(aluzzardi): BROKEN
246 | // if err := env.loadStateFromNotes(ctx, worktreePath); err != nil {
247 | // return nil, fmt.Errorf("failed to load state from notes: %w", err)
248 | // }
249 |
250 | // for _, revision := range env.History {
251 | // revision.container = dag.LoadContainerFromID(dagger.ContainerID(revision.State))
252 | // }
253 | // if latest := env.History.Latest(); latest != nil {
254 | // env.container = latest.container
255 | // }
256 | }
257 |
258 | func (env *Environment) buildBase(ctx context.Context) (*dagger.Container, error) {
259 | sourceDir := dag.Host().Directory(env.Worktree)
260 |
261 | container := dag.
262 | Container().
263 | From(env.BaseImage).
264 | WithWorkdir(env.Workdir)
265 |
266 | for _, secret := range env.Secrets {
267 | k, v, found := strings.Cut(secret, "=")
268 | if !found {
269 | return nil, fmt.Errorf("invalid secret: %s", secret)
270 | }
271 | container = container.WithSecretVariable(k, dag.Secret(v))
272 | }
273 |
274 | for _, command := range env.SetupCommands {
275 | var err error
276 |
277 | container = container.WithExec([]string{"sh", "-c", command})
278 |
279 | stdout, err := container.Stdout(ctx)
280 | if err != nil {
281 | var exitErr *dagger.ExecError
282 | if errors.As(err, &exitErr) {
283 | _ = env.addGitNote(ctx,
284 | fmt.Sprintf("$ %s\nexit %d\nstdout: %s\nstderr: %s\n\n",
285 | command,
286 | exitErr.ExitCode, exitErr.Stdout, exitErr.Stderr,
287 | ),
288 | )
289 | return nil, fmt.Errorf("setup command failed with exit code %d.\nstdout: %s\nstderr: %s\n%w\n", exitErr.ExitCode, exitErr.Stdout, exitErr.Stderr, err)
290 | }
291 |
292 | return nil, fmt.Errorf("failed to execute setup command: %w", err)
293 | }
294 |
295 | _ = env.addGitNote(ctx, fmt.Sprintf("$ %s\n%s\n\n", command, stdout))
296 | }
297 |
298 | container = container.WithDirectory(".", sourceDir)
299 |
300 | return container, nil
301 | }
302 |
303 | func (env *Environment) Update(ctx context.Context, explanation, instructions, baseImage string, setupCommands, secrets []string) error {
304 | if env.isLocked(env.Source) {
305 | return fmt.Errorf("Environment is locked, no updates allowed. Try to make do with the current environment or ask a human to remove the lock file (%s)", path.Join(env.Source, configDir, lockFile))
306 | }
307 |
308 | env.Instructions = instructions
309 | env.BaseImage = baseImage
310 | env.SetupCommands = setupCommands
311 | env.Secrets = secrets
312 |
313 | // Re-build the base image from the worktree
314 | container, err := env.buildBase(ctx)
315 | if err != nil {
316 | return err
317 | }
318 |
319 | if err := env.apply(ctx, "Update environment", explanation, "", container); err != nil {
320 | return err
321 | }
322 |
323 | return env.propagateToWorktree(ctx, "Update environment "+env.Name, explanation)
324 | }
325 |
326 | func Get(idOrName string) *Environment {
327 | if environment, ok := environments[idOrName]; ok {
328 | return environment
329 | }
330 | for _, environment := range environments {
331 | if environment.Name == idOrName {
332 | return environment
333 | }
334 | }
335 | return nil
336 | }
337 |
338 | func List() []*Environment {
339 | env := make([]*Environment, 0, len(environments))
340 | for _, environment := range environments {
341 | env = append(env, environment)
342 | }
343 | return env
344 | }
345 |
346 | func (env *Environment) Run(ctx context.Context, explanation, command, shell string, useEntrypoint bool) (string, error) {
347 | args := []string{}
348 | if command != "" {
349 | args = []string{shell, "-c", command}
350 | }
351 | newState := env.container.WithExec(args, dagger.ContainerWithExecOpts{
352 | UseEntrypoint: useEntrypoint,
353 | })
354 | stdout, err := newState.Stdout(ctx)
355 | if err != nil {
356 | var exitErr *dagger.ExecError
357 | if errors.As(err, &exitErr) {
358 | _ = env.addGitNote(ctx,
359 | fmt.Sprintf("$ %s\nexit %d\nstdout: %s\nstderr: %s\n\n",
360 | command,
361 | exitErr.ExitCode, exitErr.Stdout, exitErr.Stderr,
362 | ),
363 | )
364 | return fmt.Sprintf("command failed with exit code %d.\nstdout: %s\nstderr: %s", exitErr.ExitCode, exitErr.Stdout, exitErr.Stderr), nil
365 | }
366 | return "", err
367 | }
368 | _ = env.addGitNote(ctx, fmt.Sprintf("$ %s\n%s\n\n", command, stdout))
369 | if err := env.apply(ctx, "Run "+command, explanation, stdout, newState); err != nil {
370 | return "", err
371 | }
372 |
373 | if err := env.propagateToWorktree(ctx, "Run "+command, explanation); err != nil {
374 | return "", fmt.Errorf("failed to propagate to worktree: %w", err)
375 | }
376 |
377 | return stdout, nil
378 | }
379 |
380 | type EndpointMapping struct {
381 | Internal string `json:"internal"`
382 | External string `json:"external"`
383 | }
384 |
385 | type EndpointMappings map[int]*EndpointMapping
386 |
387 | func (env *Environment) RunBackground(ctx context.Context, explanation, command, shell string, ports []int, useEntrypoint bool) (EndpointMappings, error) {
388 | args := []string{}
389 | if command != "" {
390 | args = []string{shell, "-c", command}
391 | }
392 | serviceState := env.container
393 |
394 | // Expose ports
395 | for _, port := range ports {
396 | serviceState = serviceState.WithExposedPort(port, dagger.ContainerWithExposedPortOpts{
397 | Protocol: dagger.NetworkProtocolTcp,
398 | Description: fmt.Sprintf("Port %d", port),
399 | })
400 | }
401 |
402 | // Start the service
403 | svc, err := serviceState.AsService(dagger.ContainerAsServiceOpts{
404 | Args: args,
405 | UseEntrypoint: useEntrypoint,
406 | }).Start(ctx)
407 | if err != nil {
408 | var exitErr *dagger.ExecError
409 | if errors.As(err, &exitErr) {
410 | return nil, fmt.Errorf("command failed with exit code %d.\nstdout: %s\nstderr: %s", exitErr.ExitCode, exitErr.Stdout, exitErr.Stderr)
411 | }
412 | return nil, err
413 | }
414 |
415 | _ = env.addGitNote(ctx,
416 | fmt.Sprintf("$ %s &\n\n", command),
417 | )
418 |
419 | endpoints := EndpointMappings{}
420 | hostForwards := []dagger.PortForward{}
421 |
422 | for _, port := range ports {
423 | endpoints[port] = &EndpointMapping{}
424 | hostForwards = append(hostForwards, dagger.PortForward{
425 | Backend: port,
426 | Frontend: rand.Intn(1000) + 5000,
427 | Protocol: dagger.NetworkProtocolTcp,
428 | })
429 | }
430 |
431 | // Expose ports on the host
432 | tunnel, err := dag.Host().Tunnel(svc, dagger.HostTunnelOpts{Ports: hostForwards}).Start(ctx)
433 | if err != nil {
434 | return nil, err
435 | }
436 |
437 | // Retrieve endpoints
438 | for _, forward := range hostForwards {
439 | externalEndpoint, err := tunnel.Endpoint(ctx, dagger.ServiceEndpointOpts{
440 | Port: forward.Frontend,
441 | })
442 | if err != nil {
443 | return nil, err
444 | }
445 |
446 | endpoints[forward.Backend].External = externalEndpoint
447 | }
448 | for port, endpoint := range endpoints {
449 | internalEndpoint, err := svc.Endpoint(ctx, dagger.ServiceEndpointOpts{
450 | Port: port,
451 | })
452 | if err != nil {
453 | return nil, err
454 | }
455 | endpoint.Internal = internalEndpoint
456 | }
457 |
458 | return endpoints, nil
459 | }
460 |
461 | func (env *Environment) SetEnv(ctx context.Context, explanation string, envs []string) error {
462 | state := env.container
463 | for _, env := range envs {
464 | parts := strings.SplitN(env, "=", 2)
465 | if len(parts) != 2 {
466 | return fmt.Errorf("invalid environment variable: %s", env)
467 | }
468 | state = state.WithEnvVariable(parts[0], parts[1])
469 | }
470 | return env.apply(ctx, "Set env "+strings.Join(envs, ", "), explanation, "", state)
471 | }
472 |
473 | func (env *Environment) Revert(ctx context.Context, explanation string, version Version) error {
474 | revision := env.History.Get(version)
475 | if revision == nil {
476 | return errors.New("no revisions found")
477 | }
478 | if err := env.apply(ctx, "Revert to "+revision.Name, explanation, "", revision.container); err != nil {
479 | return err
480 | }
481 | return env.propagateToWorktree(ctx, "Revert to "+revision.Name, explanation)
482 | }
483 |
484 | func (env *Environment) Fork(ctx context.Context, explanation, name string, version *Version) (*Environment, error) {
485 | revision := env.History.Latest()
486 | if version != nil {
487 | revision = env.History.Get(*version)
488 | }
489 | if revision == nil {
490 | return nil, errors.New("version not found")
491 | }
492 |
493 | forkedEnvironment := &Environment{
494 | ID: fmt.Sprintf("%s/%s", name, petname.Generate(2, "-")),
495 | Name: name,
496 | }
497 | if err := forkedEnvironment.apply(ctx, "Fork from "+env.Name, explanation, "", revision.container); err != nil {
498 | return nil, err
499 | }
500 | environments[forkedEnvironment.ID] = forkedEnvironment
501 | return forkedEnvironment, nil
502 | }
503 |
504 | func (env *Environment) Terminal(ctx context.Context) error {
505 | container := env.container
506 | // In case there's bash in the container, show the same pretty PS1 as for the default /bin/sh terminal in dagger
507 | container = container.WithNewFile("/root/.bash_aliases", `export PS1="\033[33mdagger\033[0m \033[02m\$(pwd | sed \"s|^\$HOME|~|\")\033[0m \$ "`+"\n")
508 | if _, err := container.Terminal(dagger.ContainerTerminalOpts{}).Sync(ctx); err != nil {
509 | return err
510 | }
511 | return nil
512 | }
513 |
514 | func (env *Environment) Checkpoint(ctx context.Context, target string) (string, error) {
515 | return env.container.Publish(ctx, target)
516 | }
517 |
518 | func (env *Environment) Delete(ctx context.Context) error {
519 | env.mu.Lock()
520 | defer env.mu.Unlock()
521 |
522 | if err := env.DeleteWorktree(); err != nil {
523 | return err
524 | }
525 |
526 | if err := env.DeleteLocalRemoteBranch(); err != nil {
527 | return err
528 | }
529 |
530 | // Remove from global environments map
531 | delete(environments, env.ID)
532 |
533 | return nil
534 | }
535 |
--------------------------------------------------------------------------------
/environment/filesystem.go:
--------------------------------------------------------------------------------
1 | package environment
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "path/filepath"
8 | "strings"
9 |
10 | "dagger.io/dagger"
11 | )
12 |
13 | func (s *Environment) FileRead(ctx context.Context, targetFile string, shouldReadEntireFile bool, startLineOneIndexed int, endLineOneIndexedInclusive int) (string, error) {
14 | file, err := s.container.File(targetFile).Contents(ctx)
15 | if err != nil {
16 | return "", err
17 | }
18 | if shouldReadEntireFile {
19 | return string(file), err
20 | }
21 |
22 | lines := strings.Split(string(file), "\n")
23 | start := startLineOneIndexed - 1
24 | start = max(start, 0)
25 | if start >= len(lines) {
26 | start = len(lines) - 1
27 | }
28 | end := endLineOneIndexedInclusive
29 | if end >= len(lines) {
30 | end = len(lines) - 1
31 | }
32 | if end < 0 {
33 | end = 0
34 | }
35 | return strings.Join(lines[start:end], "\n"), nil
36 | }
37 |
38 | func (s *Environment) FileWrite(ctx context.Context, explanation, targetFile, contents string) error {
39 | err := s.apply(ctx, "Write "+targetFile, explanation, "", s.container.WithNewFile(targetFile, contents))
40 | if err != nil {
41 | return fmt.Errorf("failed applying file write, skipping git propogation: %w", err)
42 | }
43 |
44 | return s.propagateToWorktree(ctx, "Write "+targetFile, explanation)
45 | }
46 |
47 | func (s *Environment) FileDelete(ctx context.Context, explanation, targetFile string) error {
48 | err := s.apply(ctx, "Delete "+targetFile, explanation, "", s.container.WithoutFile(targetFile))
49 | if err != nil {
50 | return err
51 | }
52 |
53 | return s.propagateToWorktree(ctx, "Delete "+targetFile, explanation)
54 | }
55 |
56 | func (s *Environment) FileList(ctx context.Context, path string) (string, error) {
57 | entries, err := s.container.Directory(path).Entries(ctx)
58 | if err != nil {
59 | return "", err
60 | }
61 | out := &strings.Builder{}
62 | for _, entry := range entries {
63 | fmt.Fprintf(out, "%s\n", entry)
64 | }
65 | return out.String(), nil
66 | }
67 |
68 | func urlToDirectory(url string) *dagger.Directory {
69 | switch {
70 | case strings.HasPrefix(url, "file://"):
71 | return dag.Host().Directory(url[len("file://"):])
72 | case strings.HasPrefix(url, "git://"):
73 | return dag.Git(url[len("git://"):]).Head().Tree()
74 | case strings.HasPrefix(url, "https://"):
75 | return dag.Git(url[len("https://"):]).Head().Tree()
76 | default:
77 | return dag.Host().Directory(url)
78 | }
79 | }
80 |
81 | func (s *Environment) Upload(ctx context.Context, explanation, source string, target string) error {
82 | err := s.apply(ctx, "Upload "+source+" to "+target, explanation, "", s.container.WithDirectory(target, urlToDirectory(source)))
83 | if err != nil {
84 | return err
85 | }
86 |
87 | return s.propagateToWorktree(ctx, "Upload "+source+" to "+target, explanation)
88 | }
89 |
90 | func (s *Environment) Download(ctx context.Context, source string, target string) error {
91 | if _, err := s.container.Directory(source).Export(ctx, target); err != nil {
92 | if strings.Contains(err.Error(), "not a directory") {
93 | if _, err := s.container.File(source).Export(ctx, target); err != nil {
94 | return err
95 | }
96 | return nil
97 | }
98 | return err
99 | }
100 |
101 | return nil
102 | }
103 |
104 | func (s *Environment) RemoteDiff(ctx context.Context, source string, target string) (string, error) {
105 | sourceDir := urlToDirectory(source)
106 | targetDir := s.container.Directory(target)
107 |
108 | diff, err := dag.Container().From(alpineImage).
109 | WithMountedDirectory("/source", sourceDir).
110 | WithMountedDirectory("/target", targetDir).
111 | WithExec([]string{"diff", "-burN", "/source", "/target"}, dagger.ContainerWithExecOpts{
112 | Expect: dagger.ReturnTypeAny,
113 | }).
114 | Stdout(ctx)
115 | if err != nil {
116 | var exitErr *dagger.ExecError
117 | if errors.As(err, &exitErr) {
118 | return fmt.Sprintf("command failed with exit code %d.\nstdout: %s\nstderr: %s", exitErr.ExitCode, exitErr.Stdout, exitErr.Stderr), nil
119 | }
120 | return "", err
121 | }
122 | return diff, nil
123 | }
124 | func (s *Environment) RevisionDiff(ctx context.Context, path string, fromVersion, toVersion Version) (string, error) {
125 | revisionDiff, err := s.revisionDiff(ctx, path, fromVersion, toVersion, true)
126 | if err != nil {
127 | if strings.Contains(err.Error(), "not a directory") {
128 | return s.revisionDiff(ctx, path, fromVersion, toVersion, false)
129 | }
130 | return "", err
131 | }
132 | return revisionDiff, nil
133 | }
134 |
135 | func (s *Environment) revisionDiff(ctx context.Context, path string, fromVersion, toVersion Version, directory bool) (string, error) {
136 | if path == "" {
137 | path = s.Workdir
138 | }
139 | diffCtr := dag.Container().
140 | From(alpineImage).
141 | WithWorkdir("/diffs")
142 | if directory {
143 | diffCtr = diffCtr.
144 | WithMountedDirectory(
145 | filepath.Join("versions", fmt.Sprintf("%d", fromVersion)),
146 | s.History.Get(fromVersion).container.Directory(path)).
147 | WithMountedDirectory(
148 | filepath.Join("versions", fmt.Sprintf("%d", toVersion)),
149 | s.History.Get(toVersion).container.Directory(path))
150 | } else {
151 | diffCtr = diffCtr.
152 | WithMountedFile(
153 | filepath.Join("versions", fmt.Sprintf("%d", fromVersion)),
154 | s.History.Get(fromVersion).container.File(path)).
155 | WithMountedFile(
156 | filepath.Join("versions", fmt.Sprintf("%d", toVersion)),
157 | s.History.Get(toVersion).container.File(path))
158 | }
159 |
160 | diffCmd := []string{"diff", "-burN",
161 | filepath.Join("versions", fmt.Sprintf("%d", fromVersion)),
162 | filepath.Join("versions", fmt.Sprintf("%d", toVersion)),
163 | }
164 | diff, err := diffCtr.
165 | WithExec(diffCmd, dagger.ContainerWithExecOpts{
166 | Expect: dagger.ReturnTypeAny,
167 | }).
168 | Stdout(ctx)
169 | if err != nil {
170 | var exitErr *dagger.ExecError
171 | if errors.As(err, &exitErr) {
172 | return fmt.Sprintf("command failed with exit code %d.\nstdout: %s\nstderr: %s", exitErr.ExitCode, exitErr.Stdout, exitErr.Stderr), nil
173 | }
174 | return "", err
175 | }
176 | return diff, nil
177 | }
178 |
--------------------------------------------------------------------------------
/environment/git.go:
--------------------------------------------------------------------------------
1 | package environment
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "errors"
7 | "fmt"
8 | "log/slog"
9 | "os"
10 | "os/exec"
11 | "path/filepath"
12 | "slices"
13 | "strings"
14 |
15 | "dagger.io/dagger"
16 | "github.com/mitchellh/go-homedir"
17 | )
18 |
19 | const (
20 | containerUseRemote = "container-use"
21 | gitNotesLogRef = "container-use"
22 | gitNotesStateRef = "container-use-state"
23 | )
24 |
25 | // 10MB
26 | const maxFileSizeForTextCheck = 10 * 1024 * 1024
27 |
28 | func getRepoPath(repoName string) (string, error) {
29 | return homedir.Expand(fmt.Sprintf(
30 | "~/.config/container-use/repos/%s",
31 | filepath.Base(repoName),
32 | ))
33 | }
34 |
35 | func (env *Environment) GetWorktreePath() (string, error) {
36 | return homedir.Expand(fmt.Sprintf("~/.config/container-use/worktrees/%s", env.ID))
37 | }
38 |
39 | func (env *Environment) DeleteWorktree() error {
40 | worktreePath, err := env.GetWorktreePath()
41 | if err != nil {
42 | return err
43 | }
44 | parentDir := filepath.Dir(worktreePath)
45 | fmt.Printf("Deleting parent directory of worktree at %s\n", parentDir)
46 | return os.RemoveAll(parentDir)
47 | }
48 |
49 | func (env *Environment) DeleteLocalRemoteBranch() error {
50 | localRepoPath, err := filepath.Abs(env.Source)
51 | if err != nil {
52 | slog.Error("Failed to get absolute path for local repo", "source", env.Source, "err", err)
53 | return err
54 | }
55 | repoName := filepath.Base(localRepoPath)
56 | cuRepoPath, err := getRepoPath(repoName)
57 |
58 | slog.Info("Pruning git worktrees", "repo", cuRepoPath)
59 | if _, err = runGitCommand(context.Background(), cuRepoPath, "worktree", "prune"); err != nil {
60 | slog.Error("Failed to prune git worktrees", "repo", cuRepoPath, "err", err)
61 | return err
62 | }
63 |
64 | slog.Info("Deleting local branch", "repo", cuRepoPath, "branch", env.ID)
65 | if _, err = runGitCommand(context.Background(), cuRepoPath, "branch", "-D", env.ID); err != nil {
66 | slog.Error("Failed to delete local branch", "repo", cuRepoPath, "branch", env.ID, "err", err)
67 | return err
68 | }
69 |
70 | if _, err = runGitCommand(context.Background(), localRepoPath, "remote", "prune", containerUseRemote); err != nil {
71 | slog.Error("Failed to fetch and prune container-use remote", "local-repo", localRepoPath, "err", err)
72 | return err
73 | }
74 |
75 | return nil
76 | }
77 |
78 | func (env *Environment) InitializeWorktree(ctx context.Context, localRepoPath string) (string, error) {
79 | localRepoPath, err := filepath.Abs(localRepoPath)
80 | if err != nil {
81 | return "", err
82 | }
83 |
84 | cuRepoPath, err := InitializeLocalRemote(ctx, localRepoPath)
85 | if err != nil {
86 | return "", err
87 | }
88 |
89 | worktreePath, err := env.GetWorktreePath()
90 | if err != nil {
91 | return "", err
92 | }
93 |
94 | if _, err := os.Stat(worktreePath); err == nil {
95 | return worktreePath, nil
96 | }
97 |
98 | slog.Info("Initializing worktree", "container-id", env.ID, "container-name", env.Name, "id", env.ID)
99 | _, err = runGitCommand(ctx, localRepoPath, "fetch", containerUseRemote)
100 | if err != nil {
101 | return "", err
102 | }
103 |
104 | currentBranch, err := runGitCommand(ctx, localRepoPath, "branch", "--show-current")
105 | if err != nil {
106 | return "", err
107 | }
108 | currentBranch = strings.TrimSpace(currentBranch)
109 |
110 | // this is racy, i think? like if a human is rewriting history on a branch and creating containers, things get complicated.
111 | // there's only 1 copy of the source branch in the localremote, so there's potential for conflicts.
112 | _, err = runGitCommand(ctx, localRepoPath, "push", containerUseRemote, "--force", currentBranch)
113 | if err != nil {
114 | return "", err
115 | }
116 |
117 | // create worktree, accomodating past partial failures where the branch pushed but the worktree wasn't created
118 | _, err = runGitCommand(ctx, cuRepoPath, "show-ref", "--verify", "--quiet", fmt.Sprintf("refs/heads/%s", env.ID))
119 | if err != nil {
120 | _, err = runGitCommand(ctx, cuRepoPath, "worktree", "add", "-b", env.ID, worktreePath, currentBranch)
121 | if err != nil {
122 | return "", err
123 | }
124 | } else {
125 | _, err = runGitCommand(ctx, cuRepoPath, "worktree", "add", worktreePath, env.ID)
126 | if err != nil {
127 | return "", err
128 | }
129 | }
130 |
131 | if err := env.applyUncommittedChanges(ctx, localRepoPath, worktreePath); err != nil {
132 | return "", fmt.Errorf("failed to apply uncommitted changes: %w", err)
133 | }
134 |
135 | _, err = runGitCommand(ctx, localRepoPath, "fetch", containerUseRemote, env.ID)
136 | if err != nil {
137 | return "", err
138 | }
139 |
140 | // set up remote tracking branch if it's not already there
141 | _, err = runGitCommand(ctx, localRepoPath, "show-ref", "--verify", "--quiet", fmt.Sprintf("refs/heads/%s", env.ID))
142 | if err != nil {
143 | _, err = runGitCommand(ctx, localRepoPath, "branch", "--track", env.ID, fmt.Sprintf("%s/%s", containerUseRemote, env.ID))
144 | if err != nil {
145 | return "", err
146 | }
147 | }
148 |
149 | return worktreePath, nil
150 | }
151 |
152 | func InitializeLocalRemote(ctx context.Context, localRepoPath string) (string, error) {
153 | localRepoPath, err := filepath.Abs(localRepoPath)
154 | if err != nil {
155 | return "", err
156 | }
157 |
158 | repoName := filepath.Base(localRepoPath)
159 | cuRepoPath, err := getRepoPath(repoName)
160 | if err != nil {
161 | return "", err
162 | }
163 |
164 | if _, err := os.Stat(cuRepoPath); err != nil {
165 | if !os.IsNotExist(err) {
166 | return "", err
167 | }
168 |
169 | slog.Info("Initializing local remote", "local-repo-path", localRepoPath, "container-use-repo-path", cuRepoPath)
170 | _, err = runGitCommand(ctx, localRepoPath, "clone", "--bare", localRepoPath, cuRepoPath)
171 | if err != nil {
172 | return "", err
173 | }
174 | }
175 |
176 | // set up local remote, updating it if it had been created previously at a different path
177 | existingURL, err := runGitCommand(ctx, localRepoPath, "remote", "get-url", containerUseRemote)
178 | if err != nil {
179 | _, err = runGitCommand(ctx, localRepoPath, "remote", "add", containerUseRemote, cuRepoPath)
180 | if err != nil {
181 | return "", err
182 | }
183 | } else {
184 | existingURL = strings.TrimSpace(existingURL)
185 | if existingURL != cuRepoPath {
186 | _, err = runGitCommand(ctx, localRepoPath, "remote", "set-url", containerUseRemote, cuRepoPath)
187 | if err != nil {
188 | return "", err
189 | }
190 | }
191 | }
192 | return cuRepoPath, nil
193 | }
194 |
195 | func runGitCommand(ctx context.Context, dir string, args ...string) (out string, rerr error) {
196 | slog.Info(fmt.Sprintf("[%s] $ git %s", dir, strings.Join(args, " ")))
197 | defer func() {
198 | slog.Info(fmt.Sprintf("[%s] $ git %s (DONE)", dir, strings.Join(args, " ")), "err", rerr)
199 | }()
200 |
201 | cmd := exec.CommandContext(ctx, "git", args...)
202 | cmd.Dir = dir
203 |
204 | output, err := cmd.CombinedOutput()
205 | if err != nil {
206 | var exitErr *exec.ExitError
207 | if errors.As(err, &exitErr) {
208 | return "", fmt.Errorf("git command failed (exit code %d): %w\nOutput: %s",
209 | exitErr.ExitCode(), err, string(output))
210 | }
211 | return "", fmt.Errorf("git command failed: %w", err)
212 | }
213 |
214 | return string(output), nil
215 | }
216 |
217 | func (env *Environment) propagateToWorktree(ctx context.Context, name, explanation string) (rerr error) {
218 | slog.Info("Propagating to worktree...",
219 | "environment.id", env.ID,
220 | "environment.name", env.Name,
221 | "workdir", env.Workdir,
222 | "id", env.ID)
223 | defer func() {
224 | slog.Info("Propagating to worktree... (DONE)",
225 | "environment.id", env.ID,
226 | "environment.name", env.Name,
227 | "workdir", env.Workdir,
228 | "id", env.ID,
229 | "err", rerr)
230 | }()
231 |
232 | worktreePath, err := env.GetWorktreePath()
233 | if err != nil {
234 | return err
235 | }
236 |
237 | _, err = env.container.Directory(env.Workdir).Export(
238 | ctx,
239 | worktreePath,
240 | dagger.DirectoryExportOpts{Wipe: true},
241 | )
242 | if err != nil {
243 | return err
244 | }
245 |
246 | slog.Info("Saving environment")
247 | if err := env.save(worktreePath); err != nil {
248 | return err
249 | }
250 |
251 | if err := env.commitWorktreeChanges(ctx, worktreePath, name, explanation); err != nil {
252 | return fmt.Errorf("failed to commit worktree changes: %w", err)
253 | }
254 |
255 | if err := env.commitStateToNotes(ctx); err != nil {
256 | return fmt.Errorf("failed to add notes: %w", err)
257 | }
258 |
259 | localRepoPath, err := filepath.Abs(env.Source)
260 | if err != nil {
261 | return err
262 | }
263 |
264 | slog.Info("Fetching container-use remote in source repository")
265 | if _, err := runGitCommand(ctx, localRepoPath, "fetch", containerUseRemote, env.ID); err != nil {
266 | return err
267 | }
268 |
269 | if err := env.propagateGitNotes(ctx, gitNotesStateRef); err != nil {
270 | return err
271 | }
272 |
273 | return nil
274 | }
275 |
276 | func (env *Environment) propagateGitNotes(ctx context.Context, ref string) error {
277 | fullRef := fmt.Sprintf("refs/notes/%s", ref)
278 | fetch := func() error {
279 | _, err := runGitCommand(ctx, env.Source, "fetch", containerUseRemote, fullRef+":"+fullRef)
280 | return err
281 | }
282 |
283 | if err := fetch(); err != nil {
284 | if strings.Contains(err.Error(), "[rejected]") {
285 | if _, err := runGitCommand(ctx, env.Source, "update-ref", "-d", fullRef); err == nil {
286 | return fetch()
287 | }
288 | }
289 | return err
290 | }
291 | return nil
292 | }
293 |
294 | func (env *Environment) commitStateToNotes(ctx context.Context) error {
295 | buff, err := json.MarshalIndent(env.History, "", " ")
296 | if err != nil {
297 | return err
298 | }
299 | f, err := os.CreateTemp(os.TempDir(), ".container-use-git-notes-*")
300 | if err != nil {
301 | return err
302 | }
303 | defer f.Close()
304 | if _, err := f.Write(buff); err != nil {
305 | return err
306 | }
307 |
308 | _, err = runGitCommand(ctx, env.Worktree, "notes", "--ref", gitNotesStateRef, "add", "-f", "-F", f.Name())
309 | if err != nil {
310 | return err
311 | }
312 | return nil
313 | }
314 |
315 | func (env *Environment) addGitNote(ctx context.Context, note string) error {
316 | _, err := runGitCommand(ctx, env.Worktree, "notes", "--ref", gitNotesLogRef, "append", "-m", note)
317 | if err != nil {
318 | return err
319 | }
320 | return env.propagateGitNotes(ctx, gitNotesLogRef)
321 | }
322 |
323 | func StateFromCommit(ctx context.Context, repoDir, commit string) (History, error) {
324 | buff, err := runGitCommand(ctx, repoDir, "notes", "--ref", gitNotesStateRef, "show")
325 | if err != nil {
326 | return nil, err
327 | }
328 |
329 | var history History
330 | if err := json.Unmarshal([]byte(buff), &history); err != nil {
331 | return nil, err
332 | }
333 | return history, nil
334 | }
335 |
336 | func (env *Environment) loadStateFromNotes(ctx context.Context, worktreePath string) error {
337 | buff, err := runGitCommand(ctx, worktreePath, "notes", "--ref", gitNotesStateRef, "show")
338 | if err != nil {
339 | if strings.Contains(err.Error(), "no note found") {
340 | return nil
341 | }
342 | return err
343 | }
344 | return json.Unmarshal([]byte(buff), &env.History)
345 | }
346 |
347 | func (env *Environment) commitWorktreeChanges(ctx context.Context, worktreePath, name, explanation string) error {
348 | status, err := runGitCommand(ctx, worktreePath, "status", "--porcelain")
349 | if err != nil {
350 | return err
351 | }
352 |
353 | if strings.TrimSpace(status) == "" {
354 | return nil
355 | }
356 |
357 | if err := env.addNonBinaryFiles(ctx, worktreePath); err != nil {
358 | return err
359 | }
360 |
361 | commitMsg := fmt.Sprintf("%s\n\n%s", name, explanation)
362 | _, err = runGitCommand(ctx, worktreePath, "commit", "-m", commitMsg)
363 | return err
364 | }
365 |
366 | // AI slop below!
367 | // this is just to keep us moving fast because big git repos get hard to work with
368 | // and our demos like to download large dependencies.
369 | func (env *Environment) addNonBinaryFiles(ctx context.Context, worktreePath string) error {
370 | statusOutput, err := runGitCommand(ctx, worktreePath, "status", "--porcelain")
371 | if err != nil {
372 | return err
373 | }
374 |
375 | lines := strings.Split(strings.TrimSpace(statusOutput), "\n")
376 |
377 | for _, line := range lines {
378 | if line == "" {
379 | continue
380 | }
381 | if len(line) < 3 {
382 | continue
383 | }
384 |
385 | indexStatus := line[0]
386 | workTreeStatus := line[1]
387 | fileName := strings.TrimSpace(line[2:])
388 | if fileName == "" {
389 | continue
390 | }
391 |
392 | if env.shouldSkipFile(fileName) {
393 | continue
394 | }
395 |
396 | switch {
397 | case indexStatus == '?' && workTreeStatus == '?':
398 | // ?? = untracked files or directories
399 | if strings.HasSuffix(fileName, "/") {
400 | // Untracked directory - traverse and add non-binary files
401 | dirName := strings.TrimSuffix(fileName, "/")
402 | if err := env.addFilesFromUntrackedDirectory(ctx, worktreePath, dirName); err != nil {
403 | return err
404 | }
405 | } else {
406 | // Untracked file - add if not binary
407 | if !env.isBinaryFile(worktreePath, fileName) {
408 | _, err = runGitCommand(ctx, worktreePath, "add", fileName)
409 | if err != nil {
410 | return err
411 | }
412 | }
413 | }
414 | case indexStatus == 'A':
415 | // A = already staged, skip
416 | continue
417 | case indexStatus == 'D' || workTreeStatus == 'D':
418 | // D = deleted files (always stage deletion)
419 | _, err = runGitCommand(ctx, worktreePath, "add", fileName)
420 | if err != nil {
421 | return err
422 | }
423 | default:
424 | // M, R, C and other statuses - add if not binary
425 | if !env.isBinaryFile(worktreePath, fileName) {
426 | _, err = runGitCommand(ctx, worktreePath, "add", fileName)
427 | if err != nil {
428 | return err
429 | }
430 | }
431 | }
432 | }
433 |
434 | return nil
435 | }
436 |
437 | func (env *Environment) shouldSkipFile(fileName string) bool {
438 | skipExtensions := []string{
439 | ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz",
440 | ".zip", ".rar", ".7z", ".gz", ".bz2", ".xz",
441 | ".exe", ".bin", ".dmg", ".pkg", ".msi",
442 | ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".svg",
443 | ".mp3", ".mp4", ".avi", ".mov", ".wmv", ".flv", ".mkv",
444 | ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
445 | ".so", ".dylib", ".dll", ".a", ".lib",
446 | }
447 |
448 | lowerName := strings.ToLower(fileName)
449 | for _, ext := range skipExtensions {
450 | if strings.HasSuffix(lowerName, ext) {
451 | return true
452 | }
453 | }
454 |
455 | skipPatterns := []string{
456 | "node_modules/", ".git/", "__pycache__/", ".DS_Store",
457 | "venv/", ".venv/", "env/", ".env/",
458 | "target/", "build/", "dist/", ".next/",
459 | "*.tmp", "*.temp", "*.cache", "*.log",
460 | }
461 |
462 | for _, pattern := range skipPatterns {
463 | if strings.Contains(lowerName, strings.ToLower(pattern)) {
464 | return true
465 | }
466 | }
467 |
468 | return false
469 | }
470 |
471 | func (env *Environment) applyUncommittedChanges(ctx context.Context, localRepoPath, worktreePath string) error {
472 | status, err := runGitCommand(ctx, localRepoPath, "status", "--porcelain")
473 | if err != nil {
474 | return err
475 | }
476 |
477 | if strings.TrimSpace(status) == "" {
478 | return nil
479 | }
480 |
481 | slog.Info("Applying uncommitted changes to worktree", "container-id", env.ID, "container-name", env.Name)
482 |
483 | patch, err := runGitCommand(ctx, localRepoPath, "diff", "HEAD")
484 | if err != nil {
485 | return err
486 | }
487 |
488 | if strings.TrimSpace(patch) != "" {
489 | cmd := exec.Command("git", "apply")
490 | cmd.Dir = worktreePath
491 | cmd.Stdin = strings.NewReader(patch)
492 | if err := cmd.Run(); err != nil {
493 | return fmt.Errorf("failed to apply patch: %w", err)
494 | }
495 | }
496 |
497 | untrackedFiles, err := runGitCommand(ctx, localRepoPath, "ls-files", "--others", "--exclude-standard")
498 | if err != nil {
499 | return err
500 | }
501 |
502 | for _, file := range strings.Split(strings.TrimSpace(untrackedFiles), "\n") {
503 | if file == "" {
504 | continue
505 | }
506 | srcPath := filepath.Join(localRepoPath, file)
507 | destPath := filepath.Join(worktreePath, file)
508 |
509 | if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
510 | return err
511 | }
512 |
513 | if err := exec.Command("cp", "-r", srcPath, destPath).Run(); err != nil {
514 | return fmt.Errorf("failed to copy untracked file %s: %w", file, err)
515 | }
516 | }
517 |
518 | return env.commitWorktreeChanges(ctx, worktreePath, "Copy uncommitted changes", "Applied uncommitted changes from local repository")
519 | }
520 |
521 | func (env *Environment) addFilesFromUntrackedDirectory(ctx context.Context, worktreePath, dirName string) error {
522 | dirPath := filepath.Join(worktreePath, dirName)
523 |
524 | return filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
525 | if err != nil {
526 | return err
527 | }
528 |
529 | relPath, err := filepath.Rel(worktreePath, path)
530 | if err != nil {
531 | return err
532 | }
533 |
534 | if info.IsDir() {
535 | if env.shouldSkipFile(relPath + "/") {
536 | return filepath.SkipDir
537 | }
538 | return nil
539 | }
540 |
541 | if env.shouldSkipFile(relPath) {
542 | return nil
543 | }
544 |
545 | if !env.isBinaryFile(worktreePath, relPath) {
546 | _, err = runGitCommand(ctx, worktreePath, "add", relPath)
547 | if err != nil {
548 | return err
549 | }
550 | }
551 |
552 | return nil
553 | })
554 | }
555 |
556 | func (env *Environment) isBinaryFile(worktreePath, fileName string) bool {
557 | fullPath := filepath.Join(worktreePath, fileName)
558 |
559 | stat, err := os.Stat(fullPath)
560 | if err != nil {
561 | return true
562 | }
563 |
564 | if stat.IsDir() {
565 | return false
566 | }
567 |
568 | if stat.Size() > maxFileSizeForTextCheck {
569 | return true
570 | }
571 |
572 | file, err := os.Open(fullPath)
573 | if err != nil {
574 | slog.Error("Error opening file", "err", err)
575 | return true
576 | }
577 | defer file.Close()
578 |
579 | buffer := make([]byte, 8000)
580 | n, err := file.Read(buffer)
581 | if err != nil && n == 0 {
582 | return true
583 | }
584 |
585 | buffer = buffer[:n]
586 | if slices.Contains(buffer, 0) {
587 | return true
588 | }
589 |
590 | return false
591 | }
592 |
--------------------------------------------------------------------------------
/examples/hello_world.md:
--------------------------------------------------------------------------------
1 | create a simple flask app
2 |
--------------------------------------------------------------------------------
/examples/parallel.md:
--------------------------------------------------------------------------------
1 | Create 2 variations of a simple hello world app using Flask and FastAPI. each in their own environment. Give me the URL of each app
2 |
--------------------------------------------------------------------------------
/examples/security.md:
--------------------------------------------------------------------------------
1 | # Dependency Security Audit
2 |
3 | 1. Analyze project dependencies:
4 | . - Run the analysis in a sandbox using the latest Go version.
5 | - Check go.mod
6 | - List all dependencies with versions
7 | - Identify outdated packages
8 |
9 | 2. Security check:
10 | - Check for known vulnerabilities in Go
11 | - Identify dependencies with critical security issues
12 |
13 | 3. Upgrade those packages
14 | - Perform the updates in the sandbox.
15 | - Make sure the code still builds after updating.
16 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/dagger/container-use
2 |
3 | go 1.24.3
4 |
5 | toolchain go1.24.4
6 |
7 | require (
8 | dagger.io/dagger v0.18.9
9 | github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0
10 | github.com/mark3labs/mcp-go v0.29.0
11 | github.com/mitchellh/go-homedir v1.1.0
12 | github.com/spf13/cobra v1.9.1
13 | github.com/tiborvass/go-watch v0.0.0-20250607214558-08999a83bf8b
14 | )
15 |
16 | require (
17 | github.com/99designs/gqlgen v0.17.74 // indirect
18 | github.com/Khan/genqlient v0.8.1 // indirect
19 | github.com/adrg/xdg v0.5.3 // indirect
20 | github.com/cenkalti/backoff/v5 v5.0.2 // indirect
21 | github.com/go-logr/logr v1.4.3 // indirect
22 | github.com/go-logr/stdr v1.2.2 // indirect
23 | github.com/google/uuid v1.6.0 // indirect
24 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
25 | github.com/inconshreveable/mousetrap v1.1.0 // indirect
26 | github.com/pkg/term v1.1.0 // indirect
27 | github.com/rogpeppe/go-internal v1.14.1 // indirect
28 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
29 | github.com/sosodev/duration v1.3.1 // indirect
30 | github.com/spf13/cast v1.7.1 // indirect
31 | github.com/spf13/pflag v1.0.6 // indirect
32 | github.com/vektah/gqlparser/v2 v2.5.27 // indirect
33 | github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
34 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect
35 | go.opentelemetry.io/otel v1.36.0 // indirect
36 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.12.2 // indirect
37 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.12.2 // indirect
38 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.36.0 // indirect
39 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.36.0 // indirect
40 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 // indirect
41 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0 // indirect
42 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect
43 | go.opentelemetry.io/otel/log v0.12.2 // indirect
44 | go.opentelemetry.io/otel/metric v1.36.0 // indirect
45 | go.opentelemetry.io/otel/sdk v1.36.0 // indirect
46 | go.opentelemetry.io/otel/sdk/log v0.12.2 // indirect
47 | go.opentelemetry.io/otel/sdk/metric v1.36.0 // indirect
48 | go.opentelemetry.io/otel/trace v1.36.0 // indirect
49 | go.opentelemetry.io/proto/otlp v1.7.0 // indirect
50 | golang.org/x/net v0.40.0 // indirect
51 | golang.org/x/sync v0.14.0 // indirect
52 | golang.org/x/sys v0.33.0 // indirect
53 | golang.org/x/term v0.32.0 // indirect
54 | golang.org/x/text v0.25.0 // indirect
55 | google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
56 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
57 | google.golang.org/grpc v1.72.2 // indirect
58 | google.golang.org/protobuf v1.36.6 // indirect
59 | )
60 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | dagger.io/dagger v0.18.9 h1:IXZhlGm893LuqYFpo6VHtaCAEP6Qz0VjMhLvyKQVl1Y=
2 | dagger.io/dagger v0.18.9/go.mod h1:e6Y+sAPWh04pHvBf4s3sSiOe1QMoCEcccmMv898RnZA=
3 | github.com/99designs/gqlgen v0.17.74 h1:1FuVtkXxOc87xpKio3f6sohREmec+Jvy86PcYOuwgWo=
4 | github.com/99designs/gqlgen v0.17.74/go.mod h1:a+iR6mfRLNRp++kDpooFHiPWYiWX3Yu1BIilQRHgh10=
5 | github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs=
6 | github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU=
7 | github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
8 | github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
9 | github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
10 | github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
11 | github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=
12 | github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
13 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
14 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
15 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
16 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
17 | github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0 h1:aYo8nnk3ojoQkP5iErif5Xxv0Mo0Ga/FR5+ffl/7+Nk=
18 | github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc=
19 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
20 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
21 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
22 | github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
23 | github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
24 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
25 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
26 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
27 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
28 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
29 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
30 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
31 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
32 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
33 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
34 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
35 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
36 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
37 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
38 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
39 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
40 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
41 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
42 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
43 | github.com/mark3labs/mcp-go v0.29.0 h1:sH1NBcumKskhxqYzhXfGc201D7P76TVXiT0fGVhabeI=
44 | github.com/mark3labs/mcp-go v0.29.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
45 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
46 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
47 | github.com/pkg/term v1.1.0 h1:xIAAdCMh3QIAy+5FrE8Ad8XoDhEU4ufwbaSozViP9kk=
48 | github.com/pkg/term v1.1.0/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw=
49 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
50 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
51 | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
52 | github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
53 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
54 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
55 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
56 | github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
57 | github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
58 | github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
59 | github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
60 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
61 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
62 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
63 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
64 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
65 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
66 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
67 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
68 | github.com/tiborvass/go-watch v0.0.0-20250607214558-08999a83bf8b h1:W24fsALOtQ9v3b0mK4yR8wrmhPx4lqJAMMJ+d338fqM=
69 | github.com/tiborvass/go-watch v0.0.0-20250607214558-08999a83bf8b/go.mod h1:oAWYkECp9mFVuJQQzHtoHhepQKbme1gLM4fYH0KWvzk=
70 | github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
71 | github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
72 | github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
73 | github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
74 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
75 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
76 | go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
77 | go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
78 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.12.2 h1:06ZeJRe5BnYXceSM9Vya83XXVaNGe3H1QqsvqRANQq8=
79 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.12.2/go.mod h1:DvPtKE63knkDVP88qpatBj81JxN+w1bqfVbsbCbj1WY=
80 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.12.2 h1:tPLwQlXbJ8NSOfZc4OkgU5h2A38M4c9kfHSVc4PFQGs=
81 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.12.2/go.mod h1:QTnxBwT/1rBIgAG1goq6xMydfYOBKU6KTiYF4fp5zL8=
82 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.36.0 h1:zwdo1gS2eH26Rg+CoqVQpEK1h8gvt5qyU5Kk5Bixvow=
83 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.36.0/go.mod h1:rUKCPscaRWWcqGT6HnEmYrK+YNe5+Sw64xgQTOJ5b30=
84 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.36.0 h1:gAU726w9J8fwr4qRDqu1GYMNNs4gXrU+Pv20/N1UpB4=
85 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.36.0/go.mod h1:RboSDkp7N292rgu+T0MgVt2qgFGu6qa1RpZDOtpL76w=
86 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 h1:dNzwXjZKpMpE2JhmO+9HsPl42NIXFIFSUSSs0fiqra0=
87 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0=
88 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0 h1:JgtbA0xkWHnTmYk7YusopJFX6uleBmAuZ8n05NEh8nQ=
89 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0/go.mod h1:179AK5aar5R3eS9FucPy6rggvU0g52cvKId8pv4+v0c=
90 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 h1:nRVXXvf78e00EwY6Wp0YII8ww2JVWshZ20HfTlE11AM=
91 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ=
92 | go.opentelemetry.io/otel/log v0.12.2 h1:yob9JVHn2ZY24byZeaXpTVoPS6l+UrrxmxmPKohXTwc=
93 | go.opentelemetry.io/otel/log v0.12.2/go.mod h1:ShIItIxSYxufUMt+1H5a2wbckGli3/iCfuEbVZi/98E=
94 | go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
95 | go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
96 | go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
97 | go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
98 | go.opentelemetry.io/otel/sdk/log v0.12.2 h1:yNoETvTByVKi7wHvYS6HMcZrN5hFLD7I++1xIZ/k6W0=
99 | go.opentelemetry.io/otel/sdk/log v0.12.2/go.mod h1:DcpdmUXHJgSqN/dh+XMWa7Vf89u9ap0/AAk/XGLnEzY=
100 | go.opentelemetry.io/otel/sdk/log/logtest v0.0.0-20250521073539-a85ae98dcedc h1:uqxdywfHqqCl6LmZzI3pUnXT1RGFYyUgxj0AkWPFxi0=
101 | go.opentelemetry.io/otel/sdk/log/logtest v0.0.0-20250521073539-a85ae98dcedc/go.mod h1:TY/N/FT7dmFrP/r5ym3g0yysP1DefqGpAZr4f82P0dE=
102 | go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
103 | go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
104 | go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
105 | go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
106 | go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os=
107 | go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo=
108 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
109 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
110 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
111 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
112 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
113 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
114 | golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
115 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
116 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
117 | golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
118 | golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
119 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
120 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
121 | google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY=
122 | google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc=
123 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
124 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
125 | google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8=
126 | google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
127 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
128 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
129 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
130 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
131 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
132 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
133 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
134 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
135 |
--------------------------------------------------------------------------------
/install.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | # container-use installer script
4 | # Downloads and installs the appropriate cu binary for your system
5 |
6 | set -euo pipefail
7 |
8 | # Colors for output
9 | RED='\033[0;31m'
10 | GREEN='\033[0;32m'
11 | YELLOW='\033[1;33m'
12 | BLUE='\033[0;34m'
13 | NC='\033[0m' # No Color
14 |
15 | # Configuration
16 | REPO="dagger/container-use"
17 | BINARY_NAME="cu"
18 | INSTALL_DIR=""
19 |
20 | # Helper functions
21 | log_info() {
22 | printf "${BLUE}ℹ️ %s${NC}\n" "$1"
23 | }
24 |
25 | log_success() {
26 | printf "${GREEN}✅ %s${NC}\n" "$1"
27 | }
28 |
29 | log_warning() {
30 | printf "${YELLOW}⚠️ %s${NC}\n" "$1"
31 | }
32 |
33 | log_error() {
34 | printf "${RED}❌ %s${NC}\n" "$1"
35 | }
36 |
37 | # Check if a command exists
38 | command_exists() {
39 | command -v "$1" >/dev/null 2>&1
40 | }
41 |
42 | # Check dependencies
43 | check_dependencies() {
44 | log_info "Checking dependencies..."
45 |
46 | if ! command_exists docker; then
47 | log_error "Docker is required but not installed."
48 | log_info "Please install Docker from: https://docs.docker.com/get-started/get-docker/"
49 | exit 1
50 | fi
51 |
52 | if ! command_exists git; then
53 | log_error "Git is required but not installed."
54 | log_info "Please install Git from: https://git-scm.com/downloads"
55 | exit 1
56 | fi
57 | }
58 |
59 | # Detect operating system
60 | detect_os() {
61 | local os
62 | case "$(uname -s)" in
63 | Linux*) os="linux";;
64 | Darwin*) os="darwin";;
65 | CYGWIN*|MINGW32*|MSYS*|MINGW*)
66 | log_error "Windows is not supported"
67 | log_info "container-use uses Unix syscalls and requires Linux or macOS"
68 | exit 1;;
69 | *)
70 | log_error "Unsupported operating system: $(uname -s)"
71 | exit 1;;
72 | esac
73 | echo "$os"
74 | }
75 |
76 | # Detect architecture
77 | detect_arch() {
78 | local arch
79 | case "$(uname -m)" in
80 | x86_64|amd64) arch="amd64";;
81 | arm64|aarch64) arch="arm64";;
82 | *)
83 | log_error "Unsupported architecture: $(uname -m)"
84 | exit 1;;
85 | esac
86 | echo "$arch"
87 | }
88 |
89 | # Check for existing cu command and warn about conflicts
90 | check_existing_cu() {
91 | local found_binary=$(command -v "$BINARY_NAME" 2>/dev/null || echo "")
92 |
93 | if [ -n "$found_binary" ]; then
94 | # Only warn about system paths (user paths could be previous container-use installations)
95 | case "$found_binary" in
96 | /usr/bin/* | /bin/* | /usr/local/bin/*)
97 | log_warning "Existing 'cu' command found at $found_binary"
98 | log_warning "This appears to be a system 'cu' command (likely Taylor UUCP)"
99 | log_warning "After installation, you may need to run 'hash -r' to clear command cache"
100 | log_info "Or use the full path: \$HOME/.local/bin/cu"
101 | ;;
102 | esac
103 |
104 | log_info "Installation will continue..."
105 | echo ""
106 | fi
107 | }
108 |
109 | # Find the best installation directory
110 | find_install_dir() {
111 | local install_dir="${BIN_DIR:-$HOME/.local/bin}"
112 |
113 | # Create the directory if it doesn't exist
114 | mkdir -p "$install_dir"
115 |
116 | # Check if it's writable
117 | if [ ! -w "$install_dir" ]; then
118 | log_error "$install_dir is not a writable directory"
119 | exit 1
120 | fi
121 |
122 | echo "$install_dir"
123 | }
124 |
125 | # Get the latest release version
126 | get_latest_version() {
127 | curl -s "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/'
128 | }
129 |
130 | # Verify checksum of downloaded file
131 | verify_checksum() {
132 | local archive_file="$1"
133 | local archive_name="$2"
134 | local version="$3"
135 |
136 | log_info "Verifying checksum..."
137 |
138 | # Download checksums file
139 | local checksums_url="https://github.com/$REPO/releases/download/$version/checksums.txt"
140 | local checksums_file="$(dirname "$archive_file")/checksums.txt"
141 |
142 | curl -s -L -o "$checksums_file" "$checksums_url"
143 | if [ ! -f "$checksums_file" ]; then
144 | log_error "Failed to download checksums file"
145 | return 1
146 | fi
147 |
148 | # Extract expected checksum for our file
149 | local expected_checksum=$(grep "$(basename "$archive_file")" "$checksums_file" | cut -d' ' -f1)
150 | if [ -z "$expected_checksum" ]; then
151 | log_error "Checksum not found for $(basename "$archive_file")"
152 | return 1
153 | fi
154 |
155 | # Calculate actual checksum
156 | local actual_checksum
157 | if command_exists sha256sum; then
158 | actual_checksum=$(sha256sum "$archive_file" | cut -d' ' -f1)
159 | elif command_exists shasum; then
160 | actual_checksum=$(shasum -a 256 "$archive_file" | cut -d' ' -f1)
161 | else
162 | log_warning "No SHA256 tool found, skipping checksum verification"
163 | return 0
164 | fi
165 |
166 | # Compare checksums
167 | if [ "$actual_checksum" = "$expected_checksum" ]; then
168 | log_success "Checksum verified"
169 | return 0
170 | else
171 | log_error "Checksum verification failed!"
172 | log_error "Expected: $expected_checksum"
173 | log_error "Actual: $actual_checksum"
174 | return 1
175 | fi
176 | }
177 |
178 | # Download and extract binary
179 | download_and_install() {
180 | local os="$1"
181 | local arch="$2"
182 | local version="$3"
183 | local install_dir="$4"
184 |
185 | local archive_name="container-use_${version}_${os}_${arch}"
186 | local extension="tar.gz"
187 |
188 | local download_url="https://github.com/$REPO/releases/download/$version/${archive_name}.${extension}"
189 | local temp_dir=$(mktemp -d)
190 | local archive_file="$temp_dir/${archive_name}.${extension}"
191 |
192 | log_info "Downloading $BINARY_NAME $version for $os/$arch..."
193 |
194 | curl -L -o "$archive_file" "$download_url"
195 |
196 | if [ ! -f "$archive_file" ]; then
197 | log_error "Failed to download $download_url"
198 | exit 1
199 | fi
200 |
201 | # Verify checksum
202 | if ! verify_checksum "$archive_file" "$archive_name" "$version"; then
203 | log_error "Checksum verification failed, aborting installation"
204 | exit 1
205 | fi
206 |
207 | log_info "Extracting archive..."
208 |
209 | tar -xzf "$archive_file" -C "$temp_dir"
210 |
211 | local binary_path="$temp_dir/$BINARY_NAME"
212 |
213 | if [ ! -f "$binary_path" ]; then
214 | log_error "Binary not found in archive"
215 | exit 1
216 | fi
217 |
218 | log_info "Installing to $install_dir..."
219 | mkdir -p "$install_dir"
220 | cp "$binary_path" "$install_dir/"
221 | chmod +x "$install_dir/$BINARY_NAME"
222 |
223 | # Clean up
224 | rm -rf "$temp_dir"
225 |
226 | log_success "$BINARY_NAME installed successfully!"
227 | }
228 |
229 | # Main installation process
230 | main() {
231 | # Handle command line arguments
232 | case "${1:-}" in
233 | -h|--help)
234 | echo "container-use installer"
235 | echo ""
236 | echo "Usage: $0 [options]"
237 | echo ""
238 | echo "Options:"
239 | echo " -h, --help Show this help message"
240 | echo ""
241 | echo "This script will:"
242 | echo " 1. Check for Docker installation"
243 | echo " 2. Detect your OS and architecture"
244 | echo " 3. Download the latest container-use binary"
245 | echo " 4. Install it to your PATH"
246 | exit 0
247 | ;;
248 | esac
249 |
250 | log_info "Starting container-use installation..."
251 |
252 | check_dependencies
253 |
254 | local os=$(detect_os)
255 | local arch=$(detect_arch)
256 | log_info "Detected platform: $os/$arch"
257 |
258 | local version=$(get_latest_version)
259 | if [ -z "$version" ]; then
260 | log_error "Failed to get latest release version"
261 | exit 1
262 | fi
263 | log_info "Latest version: $version"
264 |
265 | INSTALL_DIR=$(find_install_dir)
266 | log_info "Installation directory: $INSTALL_DIR"
267 |
268 | check_existing_cu
269 |
270 | download_and_install "$os" "$arch" "$version" "$INSTALL_DIR"
271 |
272 | # Check if install directory is in PATH
273 | if ! echo "$PATH" | grep -q "$INSTALL_DIR"; then
274 | log_warning "Installation directory $INSTALL_DIR is not in your PATH"
275 | log_info "Add this to your shell profile (.bashrc, .zshrc, etc.):"
276 | echo " export PATH=\"$INSTALL_DIR:\$PATH\""
277 | log_info "Then restart your terminal or run: source ~/.bashrc (or your shell's config file)"
278 | fi
279 |
280 | # Verify installation
281 | if [ -x "$INSTALL_DIR/$BINARY_NAME" ]; then
282 | log_success "Installation complete!"
283 |
284 | # Check if the correct cu command is being found in PATH
285 | local found_binary=$(command -v "$BINARY_NAME" 2>/dev/null || echo "")
286 |
287 | if [ "$found_binary" = "$INSTALL_DIR/$BINARY_NAME" ]; then
288 | log_success "$BINARY_NAME is ready to use!"
289 | elif [ -n "$found_binary" ]; then
290 | # Some other cu command is being found
291 | local help_output=$("$found_binary" --help 2>&1 || true)
292 | if echo "$help_output" | grep -q "Taylor UUCP"; then
293 | log_error "Detected Taylor UUCP 'cu' command instead of container-use"
294 | log_info "The system 'cu' command at $found_binary is taking precedence"
295 | log_info "Try running: $INSTALL_DIR/$BINARY_NAME --help"
296 | log_info "Or run 'hash -r' and try again"
297 | log_info "Or add $INSTALL_DIR to the beginning of your PATH"
298 | exit 1
299 | else
300 | log_warning "Different 'cu' command found at $found_binary"
301 | log_info "Try running: $INSTALL_DIR/$BINARY_NAME --help"
302 | log_info "Or run 'hash -r' and try again"
303 | log_info "Or add $INSTALL_DIR to the beginning of your PATH"
304 | fi
305 | else
306 | log_warning "You may need to restart your terminal or update your PATH"
307 | fi
308 |
309 | log_info "Run '$BINARY_NAME --help' to get started"
310 | else
311 | log_error "Installation verification failed"
312 | exit 1
313 | fi
314 | }
315 |
316 | main "$@"
317 |
--------------------------------------------------------------------------------
/mcpserver/tools.go:
--------------------------------------------------------------------------------
1 | package mcpserver
2 |
3 | import (
4 | "context"
5 | _ "embed"
6 | "encoding/json"
7 | "errors"
8 | "fmt"
9 | "log/slog"
10 | "strings"
11 |
12 | "github.com/dagger/container-use/environment"
13 | "github.com/dagger/container-use/rules"
14 | "github.com/mark3labs/mcp-go/mcp"
15 | "github.com/mark3labs/mcp-go/server"
16 | )
17 |
18 | func validateName(name string) error {
19 | if name == "" {
20 | return errors.New("name cannot be empty")
21 | }
22 |
23 | if strings.Contains(name, " ") {
24 | return errors.New("name cannot contain spaces, use hyphens (-) instead")
25 | }
26 |
27 | if strings.Contains(name, "_") {
28 | return errors.New("name cannot contain underscores, use hyphens (-) instead")
29 | }
30 |
31 | invalidChars := []string{"~", "^", ":", "?", "*", "[", "\\", "/", "\"", "<", ">", "|", "@", "{", "}", "..", "\t", "\n", "\r"}
32 | for _, char := range invalidChars {
33 | if strings.Contains(name, char) {
34 | return fmt.Errorf("name cannot contain '%s'", char)
35 | }
36 | }
37 |
38 | if strings.HasPrefix(name, "-") || strings.HasSuffix(name, "-") ||
39 | strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
40 | return errors.New("name cannot start or end with hyphen or dot")
41 | }
42 |
43 | if strings.HasSuffix(name, ".lock") {
44 | return errors.New("name cannot end with '.lock'")
45 | }
46 |
47 | if len(name) > 100 {
48 | return errors.New("name cannot exceed 244 bytes")
49 | }
50 |
51 | return nil
52 | }
53 |
54 | type Tool struct {
55 | Definition mcp.Tool
56 | Handler server.ToolHandlerFunc
57 | }
58 |
59 | func RunStdioServer(ctx context.Context) error {
60 | s := server.NewMCPServer(
61 | "Dagger",
62 | "1.0.0",
63 | server.WithInstructions(rules.AgentRules),
64 | )
65 |
66 | for _, t := range tools {
67 | s.AddTool(t.Definition, t.Handler)
68 | }
69 |
70 | slog.Info("starting server")
71 | return server.ServeStdio(s)
72 | }
73 |
74 | var tools = []*Tool{}
75 |
76 | func registerTool(tool ...*Tool) {
77 | for _, t := range tool {
78 | tools = append(tools, wrapTool(t))
79 | }
80 | }
81 |
82 | func wrapTool(t *Tool) *Tool {
83 | return &Tool{
84 | Definition: t.Definition,
85 | Handler: func(ctx context.Context, request mcp.CallToolRequest) (_ *mcp.CallToolResult, rerr error) {
86 | slog.Info("Calling tool", "tool", t.Definition.Name)
87 | defer func() {
88 | slog.Info("Tool call completed", "tool", t.Definition.Name, "err", rerr)
89 | }()
90 | return t.Handler(ctx, request)
91 | },
92 | }
93 | }
94 |
95 | func init() {
96 | registerTool(
97 | EnvironmentOpenTool,
98 | EnvironmentUpdateTool,
99 |
100 | // EnvironmentListTool,
101 | // EnvironmentHistoryTool,
102 | // EnvironmentRevertTool,
103 | // EnvironmentForkTool,
104 |
105 | EnvironmentRunCmdTool,
106 | // EnvironmentSetEnvTool,
107 |
108 | // EnvironmentUploadTool,
109 | // EnvironmentDownloadTool,
110 | // EnvironmentDiffTool,
111 |
112 | EnvironmentFileReadTool,
113 | EnvironmentFileListTool,
114 | EnvironmentFileWriteTool,
115 | EnvironmentFileDeleteTool,
116 | // EnvironmentRevisionDiffTool,
117 |
118 | EnvironmentCheckpointTool,
119 | )
120 | }
121 |
122 | type EnvironmentResponse struct {
123 | ID string `json:"id"`
124 | BaseImage string `json:"base_image"`
125 | SetupCommands []string `json:"setup_commands"`
126 | Instructions string `json:"instructions"`
127 | Workdir string `json:"workdir"`
128 | Branch string `json:"branch"`
129 | TrackingBranch string `json:"tracking_branch"`
130 | CheckoutCommand string `json:"checkout_command_for_human"`
131 | HostWorktreePath string `json:"host_worktree_path"`
132 | }
133 |
134 | func EnvironmentToCallResult(env *environment.Environment) (*mcp.CallToolResult, error) {
135 | worktreePath, err := env.GetWorktreePath()
136 | if err != nil {
137 | return mcp.NewToolResultErrorFromErr("failed to get worktree", err), nil
138 | }
139 | resp := &EnvironmentResponse{
140 | ID: env.ID,
141 | Instructions: env.Instructions,
142 | BaseImage: env.BaseImage,
143 | SetupCommands: env.SetupCommands,
144 | Workdir: env.Workdir,
145 | Branch: env.ID,
146 | TrackingBranch: fmt.Sprintf("container-use/%s", env.ID),
147 | CheckoutCommand: fmt.Sprintf("git checkout %s", env.ID),
148 | HostWorktreePath: worktreePath,
149 | }
150 | out, err := json.Marshal(resp)
151 | if err != nil {
152 | return mcp.NewToolResultErrorFromErr("failed to marshal response", err), nil
153 | }
154 | return mcp.NewToolResultText(string(out)), nil
155 | }
156 |
157 | var EnvironmentOpenTool = &Tool{
158 | Definition: mcp.NewTool("environment_open",
159 | mcp.WithDescription(`Opens (or creates) a development environment.
160 | The environment is the result of a the setups commands on top of the base image.
161 | Read carefully the instructions to understand the environment.
162 | DO NOT manually install toolchains inside the environment, instead explicitly call environment_update`,
163 | ),
164 | mcp.WithString("explanation",
165 | mcp.Description("One sentence explanation for why this environment is being opened or created."),
166 | ),
167 | mcp.WithString("source",
168 | mcp.Description("The source directory of the environment."), // This can be a local folder (e.g. file://) or a URL to a git repository (e.g. https://github.com/user/repo.git, git@github.com:user/repo.git)"),
169 | mcp.Required(),
170 | ),
171 | mcp.WithString("name",
172 | mcp.Description("Name of the environment. Use hyphens (-) to separate words, no spaces or underscores allowed (e.g., 'my-web-app' not 'my web app' or 'my_web_app')"),
173 | mcp.Required(),
174 | ),
175 | ),
176 | Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
177 | source, err := request.RequireString("source")
178 | if err != nil {
179 | return nil, err
180 | }
181 | name, err := request.RequireString("name")
182 | if err != nil {
183 | return nil, err
184 | }
185 | if err := validateName(name); err != nil {
186 | return mcp.NewToolResultErrorFromErr("invalid name", err), nil
187 | }
188 | // FIXME(aluzzardi): This should call `environment.Open` instead of `environment.Create` but it's currently broken
189 | env, err := environment.Create(ctx, request.GetString("explanation", ""), source, name)
190 | if err != nil {
191 | return mcp.NewToolResultErrorFromErr("failed to open environment", err), nil
192 | }
193 | return EnvironmentToCallResult(env)
194 | },
195 | }
196 |
197 | var EnvironmentUpdateTool = &Tool{
198 | Definition: mcp.NewTool("environment_update",
199 | mcp.WithDescription("Updates an environment with new instructions and toolchains."+
200 | "If the environment is missing any tools or instructions, you MUST call this function to update the environment."+
201 | "You MUST update the environment with any useful information or tools. You will be resumed with no other context than the information provided here"),
202 | mcp.WithString("explanation",
203 | mcp.Description("One sentence explanation for why this environment is being updated."),
204 | ),
205 | mcp.WithString("environment_id",
206 | mcp.Description("The ID of the environment to update."),
207 | mcp.Required(),
208 | ),
209 | mcp.WithString("instructions",
210 | mcp.Description("The instructions for the environment. This should contain any information that might be useful to operate in the environment, such as what tools are available, what commands to use to build/test/etc"),
211 | mcp.Required(),
212 | ),
213 |
214 | mcp.WithString("base_image",
215 | mcp.Description("Change the base image for the environment."),
216 | mcp.Required(),
217 | ),
218 | mcp.WithArray("setup_commands",
219 | mcp.Description("Commands that will be executed on top of the base image to set up the environment. Similar to `RUN` instructions in Dockerfiles."),
220 | mcp.Required(),
221 | mcp.Items(map[string]any{"type": "string"}),
222 | ),
223 | mcp.WithArray("secrets",
224 | mcp.Description(`Secret references in the format of "SECRET_NAME=schema://value
225 |
226 | Secrets will be available in the environment as environment variables ($SECRET_NAME).
227 |
228 | Supported schemas are:
229 | - file://PATH: local file path
230 | - env://NAME: environment variable
231 | - op:////[section-name/]: 1Password secret
232 | `),
233 | mcp.Required(),
234 | mcp.Items(map[string]any{"type": "string"}),
235 | ),
236 | ),
237 | Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
238 | envID, err := request.RequireString("environment_id")
239 | if err != nil {
240 | return nil, err
241 | }
242 | env := environment.Get(envID)
243 | if env == nil {
244 | return mcp.NewToolResultError(fmt.Sprintf("environment %s not found", envID)), nil
245 | }
246 | instructions, err := request.RequireString("instructions")
247 | if err != nil {
248 | return nil, err
249 | }
250 | baseImage, err := request.RequireString("base_image")
251 | if err != nil {
252 | return nil, err
253 | }
254 | setupCommands, err := request.RequireStringSlice("setup_commands")
255 | if err != nil {
256 | return nil, err
257 | }
258 | secrets, err := request.RequireStringSlice("secrets")
259 | if err != nil {
260 | return nil, err
261 | }
262 |
263 | if err := env.Update(ctx, request.GetString("explanation", ""), instructions, baseImage, setupCommands, secrets); err != nil {
264 | return mcp.NewToolResultErrorFromErr("failed to update environment", err), nil
265 | }
266 | return EnvironmentToCallResult(env)
267 | },
268 | }
269 |
270 | var EnvironmentListTool = &Tool{
271 | Definition: mcp.NewTool("environment_list",
272 | mcp.WithDescription("List available environments"),
273 | mcp.WithString("explanation",
274 | mcp.Description("One sentence explanation for why this environment is being listed."),
275 | ),
276 | ),
277 | Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
278 | envs := environment.List()
279 | out, err := json.Marshal(envs)
280 | if err != nil {
281 | return nil, err
282 | }
283 | return mcp.NewToolResultText(string(out)), nil
284 | },
285 | }
286 |
287 | var EnvironmentForkTool = &Tool{
288 | Definition: mcp.NewTool("environment_fork",
289 | mcp.WithDescription("Create a new environment from an existing environment."),
290 | mcp.WithString("explanation",
291 | mcp.Description("One sentence explanation for why this environment is being forked."),
292 | ),
293 | mcp.WithString("environment_id",
294 | mcp.Description("The ID of the environment to fork."),
295 | mcp.Required(),
296 | ),
297 | mcp.WithNumber("version",
298 | mcp.Description("Version of the environment to fork. Defaults to latest version."),
299 | ),
300 | mcp.WithString("name",
301 | mcp.Description("Name of the new environment. Use hyphens (-) to separate words, no spaces or underscores allowed (e.g., 'my-forked-app' not 'my forked app' or 'my_forked_app')"),
302 | mcp.Required(),
303 | ),
304 | ),
305 | Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
306 | envID, err := request.RequireString("environment_id")
307 | if err != nil {
308 | return nil, err
309 | }
310 |
311 | env := environment.Get(envID)
312 | if env == nil {
313 | return mcp.NewToolResultError(fmt.Sprintf("environment %s not found", envID)), nil
314 | }
315 |
316 | name, err := request.RequireString("name")
317 | if err != nil {
318 | return nil, err
319 | }
320 | if err := validateName(name); err != nil {
321 | return mcp.NewToolResultErrorFromErr("invalid name", err), nil
322 | }
323 |
324 | var version *environment.Version
325 | if v, ok := request.GetArguments()["version"].(environment.Version); ok {
326 | version = &v
327 | }
328 |
329 | fork, err := env.Fork(ctx, request.GetString("explanation", ""), name, version)
330 | if err != nil {
331 | return mcp.NewToolResultErrorFromErr("failed to fork environment", err), nil
332 | }
333 |
334 | return mcp.NewToolResultText("environment forked successfully into ID " + fork.ID), nil
335 | },
336 | }
337 |
338 | var EnvironmentHistoryTool = &Tool{
339 | Definition: mcp.NewTool("environment_history",
340 | mcp.WithDescription("List the history of an environment."),
341 | mcp.WithString("explanation",
342 | mcp.Description("One sentence explanation for why this environment is being listed."),
343 | ),
344 | mcp.WithString("environment_id",
345 | mcp.Description("The ID of the environment for this command. Must call `environment_create` first."),
346 | mcp.Required(),
347 | ),
348 | ),
349 | Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
350 | envID, err := request.RequireString("environment_id")
351 | if err != nil {
352 | return nil, err
353 | }
354 |
355 | env := environment.Get(envID)
356 | if env == nil {
357 | return mcp.NewToolResultError(fmt.Sprintf("environment %s not found", envID)), nil
358 | }
359 |
360 | history := env.History
361 | out, err := json.Marshal(history)
362 | if err != nil {
363 | return nil, err
364 | }
365 | return mcp.NewToolResultText(string(out)), nil
366 | },
367 | }
368 |
369 | var EnvironmentRevertTool = &Tool{
370 | Definition: mcp.NewTool("environment_revert",
371 | mcp.WithDescription("Revert the environment to a specific version."),
372 | mcp.WithString("explanation",
373 | mcp.Description("One sentence explanation for why this environment is being listed."),
374 | ),
375 | mcp.WithString("environment_id",
376 | mcp.Description("The ID of the environment for this command. Must call `environment_create` first."),
377 | mcp.Required(),
378 | ),
379 | mcp.WithNumber("version",
380 | mcp.Description("The version to revert to."),
381 | mcp.Required(),
382 | ),
383 | ),
384 | Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
385 | envID, err := request.RequireString("environment_id")
386 | if err != nil {
387 | return nil, err
388 | }
389 |
390 | env := environment.Get(envID)
391 | if env == nil {
392 | return mcp.NewToolResultError(fmt.Sprintf("environment %s not found", envID)), nil
393 | }
394 |
395 | version, err := request.RequireInt("version")
396 | if err != nil {
397 | return nil, err
398 | }
399 |
400 | if err := env.Revert(ctx, request.GetString("explanation", ""), environment.Version(version)); err != nil {
401 | return mcp.NewToolResultErrorFromErr("failed to revert environment", err), nil
402 | }
403 |
404 | return mcp.NewToolResultText("environment reverted successfully"), nil
405 | },
406 | }
407 |
408 | var EnvironmentRunCmdTool = &Tool{
409 | Definition: mcp.NewTool("environment_run_cmd",
410 | mcp.WithDescription("Run a command on behalf of the user in the terminal."),
411 | mcp.WithString("explanation",
412 | mcp.Description("One sentence explanation for why this command is being run."),
413 | ),
414 | mcp.WithString("environment_id",
415 | mcp.Description("The ID of the environment for this command. Must call `environment_create` first."),
416 | mcp.Required(),
417 | ),
418 | mcp.WithString("command",
419 | mcp.Description("The terminal command to execute. If empty, the environment's default command is used."),
420 | ),
421 | mcp.WithString("shell",
422 | mcp.Description("The shell that will be interpreting this command (default: sh)"),
423 | ),
424 | mcp.WithBoolean("background",
425 | mcp.Description(`Run the command in the background
426 | Must ALWAYS be set for long running command (e.g. http server).
427 | Failure to do so will result in the tool being stuck, awaiting for the command to finish.`,
428 | ),
429 | ),
430 | mcp.WithBoolean("use_entrypoint",
431 | mcp.Description("Use the image entrypoint, if present, by prepending it to the args."),
432 | ),
433 | mcp.WithArray("ports",
434 | mcp.Description("Ports to expose. Only works with background environments. For each port, returns the internal (for use by other environments) and external (for use by the user) address."),
435 | mcp.Items(map[string]any{"type": "number"}),
436 | ),
437 | ),
438 | Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
439 | envID, err := request.RequireString("environment_id")
440 | if err != nil {
441 | return nil, err
442 | }
443 | env := environment.Get(envID)
444 | if env == nil {
445 | return mcp.NewToolResultError(fmt.Sprintf("environment %s not found", envID)), nil
446 | }
447 | command := request.GetString("command", "")
448 | shell := request.GetString("shell", "sh")
449 |
450 | background := request.GetBool("background", false)
451 | if background {
452 | ports := []int{}
453 | if portList, ok := request.GetArguments()["ports"].([]any); ok {
454 | for _, port := range portList {
455 | ports = append(ports, int(port.(float64)))
456 | }
457 | }
458 | endpoints, err := env.RunBackground(ctx, request.GetString("explanation", ""), command, shell, ports, request.GetBool("use_entrypoint", false))
459 | if err != nil {
460 | return mcp.NewToolResultErrorFromErr("failed to run command", err), nil
461 | }
462 |
463 | out, err := json.Marshal(endpoints)
464 | if err != nil {
465 | return nil, err
466 | }
467 |
468 | return mcp.NewToolResultText(fmt.Sprintf(`Command started in the background. Endpoints are %s
469 |
470 | Any changes to the container workdir (%s) WILL NOT be committed to container-use/%s
471 |
472 | Background commands are unaffected by filesystem and any other kind of changes. You need to start a new command for changes to take effect.`,
473 | string(out), env.Workdir, env.ID)), nil
474 | }
475 |
476 | stdout, err := env.Run(ctx, request.GetString("explanation", ""), command, shell, request.GetBool("use_entrypoint", false))
477 | if err != nil {
478 | return mcp.NewToolResultErrorFromErr("failed to run command", err), nil
479 | }
480 | return mcp.NewToolResultText(fmt.Sprintf("%s\n\nAny changes to the container workdir (%s) have been committed and pushed to container-use/%s", stdout, env.Workdir, env.ID)), nil
481 | },
482 | }
483 |
484 | var EnvironmentSetEnvTool = &Tool{
485 | Definition: mcp.NewTool("environment_set_env",
486 | mcp.WithDescription("Set environment variables for an environment."),
487 | mcp.WithString("explanation",
488 | mcp.Description("One sentence explanation for why these environment variables are being set."),
489 | ),
490 | mcp.WithString("environment_id",
491 | mcp.Description("The ID of the environment for this command. Must call `environment_create` first."),
492 | mcp.Required(),
493 | ),
494 | mcp.WithArray("envs",
495 | mcp.Description("The environment variables to set."),
496 | mcp.Items(map[string]any{"type": "string"}),
497 | ),
498 | ),
499 | Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
500 | envID, err := request.RequireString("environment_id")
501 | if err != nil {
502 | return nil, err
503 | }
504 | env := environment.Get(envID)
505 | if env == nil {
506 | return mcp.NewToolResultError(fmt.Sprintf("environment %s not found", envID)), nil
507 | }
508 | envs, err := request.RequireStringSlice("envs")
509 | if err != nil {
510 | return nil, err
511 | }
512 | if err := env.SetEnv(ctx, request.GetString("explanation", ""), envs); err != nil {
513 | return mcp.NewToolResultErrorFromErr("failed to set environment variables", err), nil
514 | }
515 | return mcp.NewToolResultText("environment variables set successfully"), nil
516 | },
517 | }
518 |
519 | var EnvironmentUploadTool = &Tool{
520 | Definition: mcp.NewTool("environment_upload",
521 | mcp.WithDescription("Upload files to an environment."),
522 | mcp.WithString("explanation",
523 | mcp.Description("One sentence explanation for why this file is being uploaded."),
524 | ),
525 | mcp.WithString("environment_id",
526 | mcp.Description("The ID of the environment for this command. Must call `environment_create` first."),
527 | mcp.Required(),
528 | ),
529 | mcp.WithString("source",
530 | mcp.Description("The source directory to be uploaded to the environment. This can be a local folder (e.g. file://) or a URL to a git repository (e.g. https://github.com/user/repo.git, git@github.com:user/repo.git)"),
531 | mcp.Required(),
532 | ),
533 | mcp.WithString("target",
534 | mcp.Description("The target destination in the environment where to upload files."),
535 | mcp.Required(),
536 | ),
537 | ),
538 | Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
539 | envID, err := request.RequireString("environment_id")
540 | if err != nil {
541 | return nil, err
542 | }
543 | env := environment.Get(envID)
544 | if env == nil {
545 | return mcp.NewToolResultError(fmt.Sprintf("environment %s not found", envID)), nil
546 | }
547 |
548 | source, err := request.RequireString("source")
549 | if err != nil {
550 | return nil, err
551 | }
552 | target, err := request.RequireString("target")
553 | if err != nil {
554 | return nil, err
555 | }
556 |
557 | if err := env.Upload(ctx, request.GetString("explanation", ""), source, target); err != nil {
558 | return mcp.NewToolResultErrorFromErr("failed to upload files", err), nil
559 | }
560 |
561 | return mcp.NewToolResultText("files uploaded successfully"), nil
562 | },
563 | }
564 |
565 | var EnvironmentDownloadTool = &Tool{
566 | Definition: mcp.NewTool("environment_download",
567 | mcp.WithDescription("Download files from an environment to the local filesystem."),
568 | mcp.WithString("explanation",
569 | mcp.Description("One sentence explanation for why this file is being downloaded."),
570 | ),
571 | mcp.WithString("environment_id",
572 | mcp.Description("The ID of the environment for this command. Must call `environment_create` first."),
573 | mcp.Required(),
574 | ),
575 | mcp.WithString("source",
576 | mcp.Description("The source directory to be downloaded from the environment."),
577 | mcp.Required(),
578 | ),
579 | mcp.WithString("target",
580 | mcp.Description("The target destination on the local filesystem where to download files."),
581 | mcp.Required(),
582 | ),
583 | ),
584 | Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
585 | envID, err := request.RequireString("environment_id")
586 | if err != nil {
587 | return nil, err
588 | }
589 | env := environment.Get(envID)
590 | if env == nil {
591 | return mcp.NewToolResultError(fmt.Sprintf("environment %s not found", envID)), nil
592 | }
593 |
594 | source, err := request.RequireString("source")
595 | if err != nil {
596 | return nil, err
597 | }
598 | target, err := request.RequireString("target")
599 | if err != nil {
600 | return nil, errors.New("target must be a string")
601 | }
602 |
603 | if err := env.Download(ctx, source, target); err != nil {
604 | return mcp.NewToolResultErrorFromErr("failed to download files", err), nil
605 | }
606 |
607 | return mcp.NewToolResultText(fmt.Sprintf("files downloaded successfully to %s", target)), nil
608 | },
609 | }
610 |
611 | var EnvironmentDiffTool = &Tool{
612 | Definition: mcp.NewTool("environment_remote_diff",
613 | mcp.WithDescription("Diff files between an environment and the local filesystem or git repository."),
614 | mcp.WithString("explanation",
615 | mcp.Description("One sentence explanation for why this diff is being run."),
616 | ),
617 | mcp.WithString("environment_id",
618 | mcp.Description("The ID of the environment for this command. Must call `environment_create` first."),
619 | mcp.Required(),
620 | ),
621 | mcp.WithString("source",
622 | mcp.Description("The source directory to be compared. This can be a local folder (e.g. file://) or a URL to a git repository (e.g. https://github.com/user/repo.git, git@github.com:user/repo.git)"),
623 | mcp.Required(),
624 | ),
625 | mcp.WithString("target",
626 | mcp.Description("The target destination on the environment filesystem where to compare against."),
627 | mcp.Required(),
628 | ),
629 | ),
630 | Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
631 | envID, err := request.RequireString("environment_id")
632 | if err != nil {
633 | return nil, err
634 | }
635 | env := environment.Get(envID)
636 | if env == nil {
637 | return mcp.NewToolResultError(fmt.Sprintf("environment %s not found", envID)), nil
638 | }
639 |
640 | source, err := request.RequireString("source")
641 | if err != nil {
642 | return nil, err
643 | }
644 | target, err := request.RequireString("target")
645 | if err != nil {
646 | return nil, errors.New("target must be a string")
647 | }
648 |
649 | diff, err := env.RemoteDiff(ctx, source, target)
650 | if err != nil {
651 | return mcp.NewToolResultErrorFromErr("failed to diff", err), nil
652 | }
653 |
654 | return mcp.NewToolResultText(diff), nil
655 | },
656 | }
657 |
658 | var EnvironmentFileReadTool = &Tool{
659 | Definition: mcp.NewTool("environment_file_read",
660 | mcp.WithDescription("Read the contents of a file, specifying a line range or the entire file."),
661 | mcp.WithString("explanation",
662 | mcp.Description("One sentence explanation for why this file is being read."),
663 | ),
664 | mcp.WithString("environment_id",
665 | mcp.Description("The ID of the environment for this command. Must call `environment_create` first."),
666 | mcp.Required(),
667 | ),
668 | mcp.WithString("target_file",
669 | mcp.Description("Path of the file to read, absolute or relative to the workdir"),
670 | mcp.Required(),
671 | ),
672 | mcp.WithBoolean("should_read_entire_file",
673 | mcp.Description("Whether to read the entire file. Defaults to false."),
674 | ),
675 | mcp.WithNumber("start_line_one_indexed",
676 | mcp.Description("The one-indexed line number to start reading from (inclusive)."),
677 | ),
678 | mcp.WithNumber("end_line_one_indexed_inclusive",
679 | mcp.Description("The one-indexed line number to end reading at (inclusive)."),
680 | ),
681 | ),
682 | Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
683 | envID, err := request.RequireString("environment_id")
684 | if err != nil {
685 | return nil, err
686 | }
687 | env := environment.Get(envID)
688 | if env == nil {
689 | return mcp.NewToolResultError(fmt.Sprintf("environment %s not found", envID)), nil
690 | }
691 |
692 | targetFile, err := request.RequireString("target_file")
693 | if err != nil {
694 | return nil, err
695 | }
696 | shouldReadEntireFile := request.GetBool("should_read_entire_file", false)
697 | startLineOneIndexed := request.GetInt("start_line_one_indexed", 0)
698 | endLineOneIndexedInclusive := request.GetInt("end_line_one_indexed_inclusive", 0)
699 |
700 | fileContents, err := env.FileRead(ctx, targetFile, shouldReadEntireFile, startLineOneIndexed, endLineOneIndexedInclusive)
701 | if err != nil {
702 | return mcp.NewToolResultErrorFromErr("failed to read file", err), nil
703 | }
704 |
705 | return mcp.NewToolResultText(fileContents), nil
706 | },
707 | }
708 |
709 | var EnvironmentFileListTool = &Tool{
710 | Definition: mcp.NewTool("environment_file_list",
711 | mcp.WithDescription("List the contents of a directory"),
712 | mcp.WithString("explanation",
713 | mcp.Description("One sentence explanation for why this directory is being listed."),
714 | ),
715 | mcp.WithString("environment_id",
716 | mcp.Description("The ID of the environment for this command. Must call `environment_create` first."),
717 | mcp.Required(),
718 | ),
719 | mcp.WithString("path",
720 | mcp.Description("Path of the directory to list contents of, absolute or relative to the workdir"),
721 | mcp.Required(),
722 | ),
723 | ),
724 | Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
725 | envID, err := request.RequireString("environment_id")
726 | if err != nil {
727 | return nil, err
728 | }
729 | env := environment.Get(envID)
730 | if env == nil {
731 | return mcp.NewToolResultError(fmt.Sprintf("environment %s not found", envID)), nil
732 | }
733 |
734 | path, err := request.RequireString("path")
735 | if err != nil {
736 | return nil, err
737 | }
738 |
739 | out, err := env.FileList(ctx, path)
740 | if err != nil {
741 | return mcp.NewToolResultErrorFromErr("failed to list directory", err), nil
742 | }
743 |
744 | return mcp.NewToolResultText(out), nil
745 | },
746 | }
747 |
748 | var EnvironmentFileWriteTool = &Tool{
749 | Definition: mcp.NewTool("environment_file_write",
750 | mcp.WithDescription("Write the contents of a file."),
751 | mcp.WithString("explanation",
752 | mcp.Description("One sentence explanation for why this file is being written."),
753 | ),
754 | mcp.WithString("environment_id",
755 | mcp.Description("The ID of the environment for this command. Must call `environment_create` first."),
756 | mcp.Required(),
757 | ),
758 | mcp.WithString("target_file",
759 | mcp.Description("Path of the file to write, absolute or relative to the workdir."),
760 | mcp.Required(),
761 | ),
762 | mcp.WithString("contents",
763 | mcp.Description("Full text content of the file you want to write."),
764 | mcp.Required(),
765 | ),
766 | ),
767 | Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
768 | envID, err := request.RequireString("environment_id")
769 | if err != nil {
770 | return nil, err
771 | }
772 | env := environment.Get(envID)
773 | if env == nil {
774 | return mcp.NewToolResultError(fmt.Sprintf("environment %s not found", envID)), nil
775 | }
776 |
777 | targetFile, err := request.RequireString("target_file")
778 | if err != nil {
779 | return nil, err
780 | }
781 | contents, err := request.RequireString("contents")
782 | if err != nil {
783 | return nil, err
784 | }
785 |
786 | if err := env.FileWrite(ctx, request.GetString("explanation", ""), targetFile, contents); err != nil {
787 | return mcp.NewToolResultErrorFromErr("failed to write file", err), nil
788 | }
789 |
790 | return mcp.NewToolResultText(fmt.Sprintf("file %s written successfully, changes pushed to container-use/%s", targetFile, env.ID)), nil
791 | },
792 | }
793 |
794 | var EnvironmentFileDeleteTool = &Tool{
795 | Definition: mcp.NewTool("environment_file_delete",
796 | mcp.WithDescription("Deletes a file at the specified path."),
797 | mcp.WithString("explanation",
798 | mcp.Description("One sentence explanation for why this file is being deleted."),
799 | ),
800 | mcp.WithString("environment_id",
801 | mcp.Description("The ID of the environment for this command. Must call `environment_create` first."),
802 | mcp.Required(),
803 | ),
804 | mcp.WithString("target_file",
805 | mcp.Description("Path of the file to delete, absolute or relative to the workdir."),
806 | mcp.Required(),
807 | ),
808 | ),
809 | Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
810 | envID, err := request.RequireString("environment_id")
811 | if err != nil {
812 | return nil, err
813 | }
814 | env := environment.Get(envID)
815 | if env == nil {
816 | return mcp.NewToolResultError(fmt.Sprintf("environment %s not found", envID)), nil
817 | }
818 |
819 | targetFile, err := request.RequireString("target_file")
820 | if err != nil {
821 | return nil, err
822 | }
823 |
824 | if err := env.FileDelete(ctx, request.GetString("explanation", ""), targetFile); err != nil {
825 | return mcp.NewToolResultErrorFromErr("failed to delete file", err), nil
826 | }
827 |
828 | return mcp.NewToolResultText(fmt.Sprintf("file %s deleted successfully, changes pushed to container-use/%s", targetFile, env.ID)), nil
829 | },
830 | }
831 |
832 | var EnvironmentRevisionDiffTool = &Tool{
833 | Definition: mcp.NewTool("environment_revision_diff",
834 | mcp.WithDescription("Diff files between multiple revisions of an environment."),
835 | mcp.WithString("explanation",
836 | mcp.Description("One sentence explanation for why this diff is being run."),
837 | ),
838 | mcp.WithString("environment_id",
839 | mcp.Description("The ID of the environment for this command. Must call `environment_create` first."),
840 | mcp.Required(),
841 | ),
842 | mcp.WithString("path",
843 | mcp.Description("The path within the environment to be diffed. Defaults to workdir."),
844 | ),
845 | mcp.WithNumber("from_version",
846 | mcp.Description("Compute the diff starting from this version"),
847 | mcp.Required(),
848 | ),
849 | mcp.WithNumber("to_version",
850 | mcp.Description("Compute the diff ending at this version. Defaults to latest version."),
851 | ),
852 | ),
853 | Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
854 | envID, err := request.RequireString("environment_id")
855 | if err != nil {
856 | return nil, err
857 | }
858 | env := environment.Get(envID)
859 | if env == nil {
860 | return mcp.NewToolResultError(fmt.Sprintf("environment %s not found", envID)), nil
861 | }
862 |
863 | path := request.GetString("path", "")
864 | fromVersion, err := request.RequireInt("from_version")
865 | if err != nil {
866 | return nil, err
867 | }
868 | toVersion := request.GetInt("to_version", int(env.History.LatestVersion()))
869 |
870 | diff, err := env.RevisionDiff(ctx, path, environment.Version(fromVersion), environment.Version(toVersion))
871 | if err != nil {
872 | return mcp.NewToolResultErrorFromErr("failed to diff", err), nil
873 | }
874 |
875 | return mcp.NewToolResultText(diff), nil
876 | },
877 | }
878 |
879 | var EnvironmentCheckpointTool = &Tool{
880 | Definition: mcp.NewTool("environment_checkpoint",
881 | mcp.WithDescription("Checkpoints an environment in its current state as a container."),
882 | mcp.WithString("explanation",
883 | mcp.Description("One sentence explanation for why this checkpoint is being created."),
884 | ),
885 | mcp.WithString("environment_id",
886 | mcp.Description("The ID of the environment for this command. Must call `environment_create` first."),
887 | mcp.Required(),
888 | ),
889 | mcp.WithString("destination",
890 | mcp.Description("Container image destination to checkpoint to (e.g. registry.com/user/image:tag"),
891 | mcp.Required(),
892 | ),
893 | ),
894 | Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
895 | envID, err := request.RequireString("environment_id")
896 | if err != nil {
897 | return nil, err
898 | }
899 | env := environment.Get(envID)
900 | if env == nil {
901 | return mcp.NewToolResultError(fmt.Sprintf("environment %s not found", envID)), nil
902 | }
903 | destination, err := request.RequireString("destination")
904 | if err != nil {
905 | return nil, err
906 | }
907 |
908 | endpoint, err := env.Checkpoint(ctx, destination)
909 | if err != nil {
910 | return mcp.NewToolResultErrorFromErr("failed to checkpoint", err), nil
911 | }
912 | return mcp.NewToolResultText(fmt.Sprintf("Checkpoint pushed to %q. You MUST use the full content addressed (@sha256:...) reference in `docker` commands. The entrypoint is set to `sh`, keep that in mind when giving commands to the container.", endpoint)), nil
913 | },
914 | }
915 |
--------------------------------------------------------------------------------
/rules/agent.md:
--------------------------------------------------------------------------------
1 | ALWAYS use ONLY Environments for ANY and ALL file, code, or shell operations—NO EXCEPTIONS—even for simple or generic requests.
2 |
3 | DO NOT install or use the git cli with the environment_run_cmd tool. All environment tools will handle git operations for you. Changing ".git" yourself will compromise the integrity of your environment.
4 |
5 | You MUST inform the user how to view your work using `git checkout `. Failure to do this will make your work inaccessible to others.
6 |
--------------------------------------------------------------------------------
/rules/cursor.mdc:
--------------------------------------------------------------------------------
1 | ---
2 | description: "Container-use rules for safe containerized development"
3 | globs: ["**/*"]
4 | alwaysApply: true
5 | ---
6 |
7 | ALWAYS use ONLY Environments for ANY and ALL file, code, or shell operations—NO EXCEPTIONS—even for simple or generic requests.
8 |
9 | If the user requests git interactions, you MUST inform them that git is not supported within the container environment, but changes will be automatically propogated to their container-use git remote.
10 | You must NEVER install the git CLI.
11 | Running "rm .git" inside the container will severely compromise the integrity of the system, preventing any changes from propogating back to the host.
12 | Other git commands will not work as expected because each containers workdir is a worktree in isolation.
13 |
14 | The "checkout command for human" value returned to the user will switch to a branch that tracks the container-use remote, but won't have the changes pulled.
15 |
16 | You MUST inform the user how to view their changes using a "git checkout" command that does not include container-use/remote. Your work will be useless without reporting this!
--------------------------------------------------------------------------------
/rules/rules.go:
--------------------------------------------------------------------------------
1 | package rules
2 |
3 | import _ "embed"
4 |
5 | //go:embed agent.md
6 | var AgentRules string
7 |
--------------------------------------------------------------------------------
/uninstall.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # container-use uninstaller script
4 |
5 | set -euo pipefail
6 |
7 | main() {
8 | local BINARY_NAME="cu"
9 | local INSTALL_DIR="${BIN_DIR:-$HOME/.local/bin}"
10 | local BINARY_PATH="$INSTALL_DIR/$BINARY_NAME"
11 |
12 | if [ ! -f "$BINARY_PATH" ]; then
13 | echo "container-use not found at $BINARY_PATH"
14 | exit 1
15 | fi
16 |
17 | # Safety check: don't delete from system paths or homebrew
18 | case "$BINARY_PATH" in
19 | /usr/bin/* | /bin/* | /usr/local/bin/* | /opt/homebrew/bin/*)
20 | echo "Error: Refusing to delete from system/brew path: $BINARY_PATH"
21 | echo "This script only removes container-use from user directories"
22 | exit 1
23 | ;;
24 | esac
25 |
26 | echo "Found container-use at: $BINARY_PATH"
27 | printf "Remove this file? (y/N): "
28 | read -r response
29 |
30 | case "$response" in
31 | [yY]|[yY][eE][sS])
32 | rm -f "$BINARY_PATH"
33 | echo "Removed $BINARY_PATH"
34 | ;;
35 | *)
36 | echo "Cancelled"
37 | exit 1
38 | ;;
39 | esac
40 | }
41 |
42 | main "$@"
43 |
--------------------------------------------------------------------------------