├── .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 | Container use: Development environments for coding agents. 3 |

container-use

4 |

Containerized environments for coding agents. (📦🤖) (📦🤖) (📦🤖)

5 |

6 | Experimental 7 | 8 | 9 | 10 | 11 | Discord 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 | container-use demo 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 | --------------------------------------------------------------------------------