Initial commit
diff --git a/dockerimg/createdockerfile.go b/dockerimg/createdockerfile.go
new file mode 100644
index 0000000..e9e01b6
--- /dev/null
+++ b/dockerimg/createdockerfile.go
@@ -0,0 +1,240 @@
+package dockerimg
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io/fs"
+ "maps"
+ "net/http"
+ "slices"
+ "strings"
+ "text/template"
+
+ "sketch.dev/ant"
+)
+
+func hashInitFiles(initFiles map[string]string) string {
+ h := sha256.New()
+ for _, path := range slices.Sorted(maps.Keys(initFiles)) {
+ fmt.Fprintf(h, "%s\n%s\n\n", path, initFiles[path])
+ }
+ return hex.EncodeToString(h.Sum(nil))
+}
+
+// createDockerfile creates a Dockerfile for a git repo.
+// It expects the relevant initFiles to have been provided.
+// If the sketch binary is being executed in a sub-directory of the repository,
+// the relative path is provided on subPathWorkingDir.
+func createDockerfile(ctx context.Context, httpc *http.Client, antURL, antAPIKey string, initFiles map[string]string, subPathWorkingDir string) (string, error) {
+ if subPathWorkingDir == "." {
+ subPathWorkingDir = ""
+ } else if subPathWorkingDir != "" && subPathWorkingDir[0] != '/' {
+ subPathWorkingDir = "/" + subPathWorkingDir
+ }
+ toolCalled := false
+ var dockerfileFROM, dockerfileExtraCmds string
+ runDockerfile := func(ctx context.Context, input json.RawMessage) (string, error) {
+ // TODO: unmarshal straight into a struct
+ var m map[string]any
+ if err := json.Unmarshal(input, &m); err != nil {
+ return "", fmt.Errorf(`input=%[1]v (%[1]T), wanted a map[string]any, got: %w`, input, err)
+ }
+ var ok bool
+ dockerfileFROM, ok = m["from"].(string)
+ if !ok {
+ return "", fmt.Errorf(`input["from"]=%[1]v (%[1]T), wanted a string`, m["path"])
+ }
+ dockerfileExtraCmds, ok = m["extra_cmds"].(string)
+ if !ok {
+ return "", fmt.Errorf(`input["extra_cmds"]=%[1]v (%[1]T), wanted a string`, m["path"])
+ }
+ toolCalled = true
+ return "OK", nil
+ }
+ convo := ant.NewConvo(ctx, antAPIKey)
+ if httpc != nil {
+ convo.HTTPC = httpc
+ }
+ if antURL != "" {
+ convo.URL = antURL
+ }
+ convo.Tools = []*ant.Tool{{
+ Name: "dockerfile",
+ Description: "Helps define a Dockerfile that sets up a dev environment for this project.",
+ Run: runDockerfile,
+ InputSchema: ant.MustSchema(`{
+ "type": "object",
+ "required": ["from", "extra_cmds"],
+ "properties": {
+ "from": {
+ "type": "string",
+ "description": "The alpine base image provided to the dockerfile FROM command"
+ },
+ "extra_cmds": {
+ "type": "string",
+ "description": "Extra commands to add to the dockerfile."
+ }
+ }
+}`),
+ }}
+
+ // TODO: add semgrep, prettier -- they require node/npm/etc which is more complicated than apk
+ // If/when we do this, add them into the list of available tools in bash.go.
+ const dockerfileBase = `FROM {{.From}}
+
+RUN apk add bash git make jq sqlite gcc musl-dev linux-headers npm nodejs go github-cli ripgrep fzf
+
+ENV GOTOOLCHAIN=auto
+ENV GOPATH=/go
+ENV PATH="$GOPATH/bin:$PATH"
+
+RUN go install golang.org/x/tools/cmd/goimports@latest
+RUN go install golang.org/x/tools/gopls@latest
+RUN go install mvdan.cc/gofumpt@latest
+
+{{.ExtraCmds}}
+
+ARG GIT_USER_EMAIL
+ARG GIT_USER_NAME
+
+RUN git config --global user.email "$GIT_USER_EMAIL" && \
+ git config --global user.name "$GIT_USER_NAME"
+
+LABEL sketch_context="{{.InitFilesHash}}"
+COPY . /app
+
+WORKDIR /app{{.SubDir}}
+RUN if [ -f go.mod ]; then go mod download; fi
+
+CMD ["/bin/sketch"]`
+
+ // TODO: it's basically impossible to one-shot a python env. We need an agent loop for that.
+ // Right now the prompt contains a set of half-baked workarounds.
+
+ // If you want to edit the model prompt, run:
+ //
+ // go test ./sketch/dockerimg -httprecord ".*" -rewritewant
+ //
+ // Then look at the changes with:
+ //
+ // git diff sketch/dockerimg/testdata/*.dockerfile
+ //
+ // If the dockerfile changes are a strict improvement, commit all the changes.
+ msg := ant.Message{
+ Role: ant.MessageRoleUser,
+ Content: []ant.Content{{
+ Type: ant.ContentTypeText,
+ Text: `
+Call the dockerfile tool to create a Dockerfile.
+The parameters to dockerfile fill out the From and ExtraCmds
+template variables in the following Go template:
+
+` + "```\n" + dockerfileBase + "\n```" + `
+
+In particular:
+- Assume it is primarily a Go project. For a minimal env, prefer 1.24.2-alpine3.21 as a base image.
+- If any python is needed at all, switch to using a python alpine image as a the base and apk add go.
+ Favor using uv, and use one of these base images, depending on the preferred python version:
+ ghcr.io/astral-sh/uv:python3.13-alpine
+ ghcr.io/astral-sh/uv:python3.12-alpine
+ ghcr.io/astral-sh/uv:python3.11-alpine
+- When using pip to install packages, use: uv pip install --system.
+- Python env setup is challenging and often no required, so any RUN commands involving python tooling should be written to let docker build continue if there is a failure.
+- Include any tools particular to this repository that can be inferred from the given context.
+- Append || true to any apk add commands in case the package does not exist.
+- Do not expose any ports.
+`,
+ }},
+ }
+ if len(initFiles) > 0 {
+ msg.Content[0].Text += "Here is the content of several files from the repository that may be relevant:\n\n"
+ }
+
+ for _, name := range slices.Sorted(maps.Keys(initFiles)) {
+ msg.Content = append(msg.Content, ant.Content{
+ Type: ant.ContentTypeText,
+ Text: fmt.Sprintf("Here is the contents %s:\n<file>\n%s\n</file>\n\n", name, initFiles[name]),
+ })
+ }
+ msg.Content = append(msg.Content, ant.Content{
+ Type: ant.ContentTypeText,
+ Text: "Now call the dockerfile tool.",
+ })
+ res, err := convo.SendMessage(msg)
+ if err != nil {
+ return "", err
+ }
+ if res.StopReason != ant.StopReasonToolUse {
+ return "", fmt.Errorf("expected stop reason %q, got %q", ant.StopReasonToolUse, res.StopReason)
+ }
+ if _, err := convo.ToolResultContents(context.TODO(), res); err != nil {
+ return "", err
+ }
+ if !toolCalled {
+ return "", fmt.Errorf("no dockerfile returned")
+ }
+
+ buf := new(bytes.Buffer)
+ err = template.Must(template.New("dockerfile").Parse(dockerfileBase)).Execute(buf, map[string]string{
+ "From": dockerfileFROM,
+ "ExtraCmds": dockerfileExtraCmds,
+ "InitFilesHash": hashInitFiles(initFiles),
+ "SubDir": subPathWorkingDir,
+ })
+ if err != nil {
+ return "", fmt.Errorf("dockerfile template failed: %w", err)
+ }
+
+ return buf.String(), nil
+}
+
+// For future reference: we can find the current git branch/checkout with: git symbolic-ref -q --short HEAD || git describe --tags --exact-match 2>/dev/null || git rev-parse HEAD
+
+func readInitFiles(fsys fs.FS) (map[string]string, error) {
+ result := make(map[string]string)
+
+ err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() && (d.Name() == ".git" || d.Name() == "node_modules") {
+ return fs.SkipDir
+ }
+ if !d.Type().IsRegular() {
+ return nil
+ }
+
+ // Case 1: Check for README files
+ // TODO: find README files between the .git root (where we start)
+ // and the dir that sketch was initialized. This needs more info
+ // plumbed to this function.
+ if strings.HasPrefix(strings.ToLower(path), "readme") {
+ content, err := fs.ReadFile(fsys, path)
+ if err != nil {
+ return err
+ }
+ result[path] = string(content)
+ return nil
+ }
+
+ // Case 2: Check for GitHub workflow files
+ if strings.HasPrefix(path, ".github/workflows/") {
+ content, err := fs.ReadFile(fsys, path)
+ if err != nil {
+ return err
+ }
+ result[path] = string(content)
+ return nil
+ }
+
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ return result, nil
+}
diff --git a/dockerimg/dockerimg.go b/dockerimg/dockerimg.go
new file mode 100644
index 0000000..72110da
--- /dev/null
+++ b/dockerimg/dockerimg.go
@@ -0,0 +1,626 @@
+// Package dockerimg
+package dockerimg
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "time"
+
+ "sketch.dev/skribe"
+)
+
+// ContainerConfig holds all configuration for launching a container
+type ContainerConfig struct {
+ // SessionID is the unique identifier for this session
+ SessionID string
+
+ // LocalAddr is the initial address to use (though it may be overwritten later)
+ LocalAddr string
+
+ // SkabandAddr is the address of the skaband service if available
+ SkabandAddr string
+
+ // AntURL is the URL of the LLM service.
+ AntURL string
+
+ // AntAPIKey is the API key for LLM service.
+ AntAPIKey string
+
+ // Path is the local filesystem path to use
+ Path string
+
+ // GitUsername is the username to use for git operations
+ GitUsername string
+
+ // GitEmail is the email to use for git operations
+ GitEmail string
+
+ // OpenBrowser determines whether to open a browser automatically
+ OpenBrowser bool
+
+ // NoCleanup prevents container cleanup when set to true
+ NoCleanup bool
+
+ // ForceRebuild forces rebuilding of the Docker image even if it exists
+ ForceRebuild bool
+
+ // Host directory to copy container logs into, if not set to ""
+ ContainerLogDest string
+
+ // Path to pre-built linux sketch binary, or build a new one if set to ""
+ SketchBinaryLinux string
+
+ // Sketch client public key.
+ SketchPubKey string
+}
+
+// LaunchContainer creates a docker container for a project, installs sketch and opens a connection to it.
+// It writes status to stdout.
+func LaunchContainer(ctx context.Context, stdout, stderr io.Writer, config ContainerConfig) error {
+ if _, err := exec.LookPath("docker"); err != nil {
+ return fmt.Errorf("cannot find `docker` binary; run: brew install docker colima && colima start")
+ }
+
+ if out, err := combinedOutput(ctx, "docker", "ps"); err != nil {
+ // `docker ps` provides a good error message here that can be
+ // easily chatgpt'ed by users, so send it to the user as-is:
+ // Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?
+ return fmt.Errorf("docker ps: %s (%w)", out, err)
+ }
+
+ _, hostPort, err := net.SplitHostPort(config.LocalAddr)
+ if err != nil {
+ return err
+ }
+
+ gitRoot, err := findGitRoot(ctx, config.Path)
+ if err != nil {
+ return err
+ }
+
+ imgName, err := findOrBuildDockerImage(ctx, stdout, stderr, config.Path, gitRoot, config.AntURL, config.AntAPIKey, config.ForceRebuild)
+ if err != nil {
+ return err
+ }
+
+ linuxSketchBin := config.SketchBinaryLinux
+ if linuxSketchBin == "" {
+ linuxSketchBin, err = buildLinuxSketchBin(ctx, config.Path)
+ if err != nil {
+ return err
+ }
+ defer os.Remove(linuxSketchBin)
+ }
+
+ cntrName := imgName + "-" + config.SessionID
+ defer func() {
+ if config.NoCleanup {
+ return
+ }
+ if out, err := combinedOutput(ctx, "docker", "kill", cntrName); err != nil {
+ // TODO: print in verbose mode? fmt.Fprintf(os.Stderr, "docker kill: %s: %v\n", out, err)
+ _ = out
+ }
+ if out, err := combinedOutput(ctx, "docker", "rm", cntrName); err != nil {
+ // TODO: print in verbose mode? fmt.Fprintf(os.Stderr, "docker kill: %s: %v\n", out, err)
+ _ = out
+ }
+ }()
+
+ // errCh receives errors from operations that this function calls in separate goroutines.
+ errCh := make(chan error)
+
+ // Start the git server
+ gitSrv, err := newGitServer(gitRoot)
+ if err != nil {
+ return fmt.Errorf("failed to start git server: %w", err)
+ }
+ defer gitSrv.shutdown(ctx)
+
+ go func() {
+ errCh <- gitSrv.serve(ctx)
+ }()
+
+ // Get the current host git commit
+ var commit string
+ if out, err := combinedOutput(ctx, "git", "rev-parse", "HEAD"); err != nil {
+ return fmt.Errorf("git rev-parse HEAD: %w", err)
+ } else {
+ commit = strings.TrimSpace(string(out))
+ }
+ if out, err := combinedOutput(ctx, "git", "config", "http.receivepack", "true"); err != nil {
+ return fmt.Errorf("git config http.receivepack true: %s: %w", out, err)
+ }
+
+ relPath, err := filepath.Rel(gitRoot, config.Path)
+ if err != nil {
+ return err
+ }
+
+ // Create the sketch container
+ if err := createDockerContainer(ctx, cntrName, hostPort, relPath, imgName, config); err != nil {
+ return err
+ }
+
+ // Copy the sketch linux binary into the container
+ if out, err := combinedOutput(ctx, "docker", "cp", linuxSketchBin, cntrName+":/bin/sketch"); err != nil {
+ return fmt.Errorf("docker cp: %s, %w", out, err)
+ }
+
+ fmt.Printf("starting container %s\ncommits made by the agent will be pushed to \033[1msketch/*\033[0m\n", cntrName)
+
+ // Start the sketch container
+ if out, err := combinedOutput(ctx, "docker", "start", cntrName); err != nil {
+ return fmt.Errorf("docker start: %s, %w", out, err)
+ }
+
+ // Copies structured logs from the container to the host.
+ copyLogs := func() {
+ if config.ContainerLogDest == "" {
+ return
+ }
+ out, err := combinedOutput(ctx, "docker", "logs", cntrName)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "docker logs failed: %v\n", err)
+ return
+ }
+ logLines := strings.Split(string(out), "\n")
+ for _, logLine := range logLines {
+ if !strings.HasPrefix(logLine, "structured logs:") {
+ continue
+ }
+ logFile := strings.TrimSpace(strings.TrimPrefix(logLine, "structured logs:"))
+ srcPath := fmt.Sprintf("%s:%s", cntrName, logFile)
+ logFileName := filepath.Base(logFile)
+ dstPath := filepath.Join(config.ContainerLogDest, logFileName)
+ _, err := combinedOutput(ctx, "docker", "cp", srcPath, dstPath)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "docker cp %s %s failed: %v\n", srcPath, dstPath, err)
+ }
+ fmt.Fprintf(os.Stderr, "\ncopied container log %s to %s\n", srcPath, dstPath)
+ }
+ }
+
+ // NOTE: we want to see what the internal sketch binary prints
+ // regardless of the setting of the verbosity flag on the external
+ // binary, so reading "docker logs", which is the stdout/stderr of
+ // the internal binary is not conditional on the verbose flag.
+ appendInternalErr := func(err error) error {
+ if err == nil {
+ return nil
+ }
+ out, logsErr := combinedOutput(ctx, "docker", "logs", cntrName)
+ if err != nil {
+ return fmt.Errorf("%w; and docker logs failed: %s, %v", err, out, logsErr)
+ }
+ out = bytes.TrimSpace(out)
+ if len(out) > 0 {
+ return fmt.Errorf("docker logs: %s;\n%w", out, err)
+ }
+ return err
+ }
+
+ // Get the sketch server port from the container
+ localAddr, err := getContainerPort(ctx, cntrName)
+ if err != nil {
+ return appendInternalErr(err)
+ }
+
+ // Tell the sketch container which git server port and commit to initialize with.
+ go func() {
+ // TODO: Why is this called in a goroutine? I have found that when I pull this out
+ // of the goroutine and call it inline, then the terminal UI clears itself and all
+ // the scrollback (which is not good, but also not fatal). I can't see why it does this
+ // though, since none of the calls in postContainerInitConfig obviously write to stdout
+ // or stderr.
+ if err := postContainerInitConfig(ctx, localAddr, commit, gitSrv.gitPort); err != nil {
+ slog.ErrorContext(ctx, "LaunchContainer.postContainerInitConfig", slog.String("err", err.Error()))
+ errCh <- appendInternalErr(err)
+ }
+ }()
+
+ if config.OpenBrowser {
+ OpenBrowser(ctx, "http://"+localAddr)
+ }
+
+ go func() {
+ cmd := exec.CommandContext(ctx, "docker", "attach", cntrName)
+ cmd.Stdin = os.Stdin
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ errCh <- run(ctx, "docker attach", cmd)
+ }()
+
+ defer copyLogs()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case err := <-errCh:
+ if err != nil {
+ return appendInternalErr(fmt.Errorf("container process: %w", err))
+ }
+ return nil
+ }
+ }
+}
+
+func combinedOutput(ctx context.Context, cmdName string, args ...string) ([]byte, error) {
+ cmd := exec.CommandContext(ctx, cmdName, args...)
+ // Really only needed for the "go build" command for the linux sketch binary
+ cmd.Env = append(os.Environ(), "GOOS=linux", "CGO_ENABLED=0")
+ start := time.Now()
+
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ slog.ErrorContext(ctx, cmdName, slog.Duration("elapsed", time.Now().Sub(start)), slog.String("err", err.Error()), slog.String("path", cmd.Path), slog.String("args", fmt.Sprintf("%v", skribe.Redact(cmd.Args))))
+ } else {
+ slog.DebugContext(ctx, cmdName, slog.Duration("elapsed", time.Now().Sub(start)), slog.String("path", cmd.Path), slog.String("args", fmt.Sprintf("%v", skribe.Redact(cmd.Args))))
+ }
+ return out, err
+}
+
+func run(ctx context.Context, cmdName string, cmd *exec.Cmd) error {
+ start := time.Now()
+ err := cmd.Run()
+ if err != nil {
+ slog.ErrorContext(ctx, cmdName, slog.Duration("elapsed", time.Now().Sub(start)), slog.String("err", err.Error()), slog.String("path", cmd.Path), slog.String("args", fmt.Sprintf("%v", skribe.Redact(cmd.Args))))
+ } else {
+ slog.DebugContext(ctx, cmdName, slog.Duration("elapsed", time.Now().Sub(start)), slog.String("path", cmd.Path), slog.String("args", fmt.Sprintf("%v", skribe.Redact(cmd.Args))))
+ }
+ return err
+}
+
+type gitServer struct {
+ gitLn net.Listener
+ gitPort string
+ srv *http.Server
+}
+
+func (gs *gitServer) shutdown(ctx context.Context) {
+ gs.srv.Shutdown(ctx)
+ gs.gitLn.Close()
+}
+
+// Serve a git remote from the host for the container to fetch from and push to.
+func (gs *gitServer) serve(ctx context.Context) error {
+ slog.DebugContext(ctx, "starting git server", slog.String("git_remote_addr", "http://host.docker.internal:"+gs.gitPort+"/.git"))
+ return gs.srv.Serve(gs.gitLn)
+}
+
+func newGitServer(gitRoot string) (*gitServer, error) {
+ ret := &gitServer{}
+ gitLn, err := net.Listen("tcp4", ":0")
+ if err != nil {
+ return nil, fmt.Errorf("git listen: %w", err)
+ }
+ ret.gitLn = gitLn
+
+ srv := http.Server{
+ Handler: &gitHTTP{gitRepoRoot: gitRoot},
+ }
+ ret.srv = &srv
+
+ _, gitPort, err := net.SplitHostPort(gitLn.Addr().String())
+ if err != nil {
+ return nil, fmt.Errorf("git port: %w", err)
+ }
+ ret.gitPort = gitPort
+ return ret, nil
+}
+
+func createDockerContainer(ctx context.Context, cntrName, hostPort, relPath, imgName string, config ContainerConfig) error {
+ //, config.SessionID, config.GitUsername, config.GitEmail, config.SkabandAddr
+ // sessionID, gitUsername, gitEmail, skabandAddr string
+ cmdArgs := []string{"create",
+ "-it",
+ "--name", cntrName,
+ "-p", hostPort + ":80", // forward container port 80 to a host port
+ "-e", "ANTHROPIC_API_KEY=" + config.AntAPIKey,
+ }
+ if config.AntURL != "" {
+ cmdArgs = append(cmdArgs, "-e", "ANT_URL="+config.AntURL)
+ }
+ if config.SketchPubKey != "" {
+ cmdArgs = append(cmdArgs, "-e", "SKETCH_PUB_KEY="+config.SketchPubKey)
+ }
+ if relPath != "." {
+ cmdArgs = append(cmdArgs, "-w", "/app/"+relPath)
+ }
+ cmdArgs = append(
+ cmdArgs,
+ imgName,
+ "/bin/sketch",
+ "-unsafe",
+ "-addr=:80",
+ "-session-id="+config.SessionID,
+ "-git-username="+config.GitUsername, "-git-email="+config.GitEmail,
+ )
+ if config.SkabandAddr != "" {
+ cmdArgs = append(cmdArgs, "-skaband-addr="+config.SkabandAddr)
+ }
+ if out, err := combinedOutput(ctx, "docker", cmdArgs...); err != nil {
+ return fmt.Errorf("docker create: %s, %w", out, err)
+ }
+ return nil
+}
+
+func buildLinuxSketchBin(ctx context.Context, path string) (string, error) {
+ start := time.Now()
+ linuxSketchBin := filepath.Join(path, "tmp-sketch-binary-linux")
+ cmd := exec.CommandContext(ctx, "go", "build", "-o", linuxSketchBin, "sketch.dev/cmd/sketch")
+ cmd.Env = append(os.Environ(), "GOOS=linux", "CGO_ENABLED=0")
+
+ fmt.Printf("building linux agent binary...\n")
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ slog.ErrorContext(ctx, "go", slog.Duration("elapsed", time.Now().Sub(start)), slog.String("err", err.Error()), slog.String("path", cmd.Path), slog.String("args", fmt.Sprintf("%v", skribe.Redact(cmd.Args))))
+ return "", fmt.Errorf("failed to build linux sketch binary: %s: %w", out, err)
+ } else {
+ slog.DebugContext(ctx, "go", slog.Duration("elapsed", time.Now().Sub(start)), slog.String("path", cmd.Path), slog.String("args", fmt.Sprintf("%v", skribe.Redact(cmd.Args))))
+ }
+
+ fmt.Printf("built linux agent binary in %s\n", time.Since(start).Round(100*time.Millisecond))
+
+ return linuxSketchBin, nil
+}
+
+func getContainerPort(ctx context.Context, cntrName string) (string, error) {
+ localAddr := ""
+ if out, err := combinedOutput(ctx, "docker", "port", cntrName, "80"); err != nil {
+ return "", fmt.Errorf("failed to find container port: %s: %v", out, err)
+ } else {
+ v4, _, found := strings.Cut(string(out), "\n")
+ if !found {
+ return "", fmt.Errorf("failed to find container port: %s: %v", out, err)
+ }
+ localAddr = v4
+ if strings.HasPrefix(localAddr, "0.0.0.0") {
+ localAddr = "127.0.0.1" + strings.TrimPrefix(localAddr, "0.0.0.0")
+ }
+ }
+ return localAddr, nil
+}
+
+// Contact the container and configure it.
+func postContainerInitConfig(ctx context.Context, localAddr, commit, gitPort string) error {
+ localURL := "http://" + localAddr
+ initMsg, err := json.Marshal(map[string]string{
+ "commit": commit,
+ "git_remote_addr": "http://host.docker.internal:" + gitPort + "/.git",
+ "host_addr": localAddr,
+ })
+ if err != nil {
+ return fmt.Errorf("init msg: %w", err)
+ }
+
+ slog.DebugContext(ctx, "/init POST", slog.String("initMsg", string(initMsg)))
+
+ // Note: this /init POST is handled in loop/server/loophttp.go:
+ initMsgByteReader := bytes.NewReader(initMsg)
+ req, err := http.NewRequest("POST", localURL+"/init", initMsgByteReader)
+ if err != nil {
+ return err
+ }
+
+ var res *http.Response
+ for i := 0; ; i++ {
+ time.Sleep(100 * time.Millisecond)
+ // If you DON'T reset this byteReader, then subsequent retries may end up sending 0 bytes.
+ initMsgByteReader.Reset(initMsg)
+ res, err = http.DefaultClient.Do(req)
+ if err != nil {
+ // In addition to "connection refused", we also occasionally see "EOF" errors that can succeed on retries.
+ if i < 100 && (strings.Contains(err.Error(), "connection refused") || strings.Contains(err.Error(), "EOF")) {
+ slog.DebugContext(ctx, "postContainerInitConfig retrying", slog.Int("retry", i), slog.String("err", err.Error()))
+ continue
+ }
+ return fmt.Errorf("failed to %s/init sketch in container, NOT retrying: err: %v", localURL, err)
+ }
+ break
+ }
+ resBytes, _ := io.ReadAll(res.Body)
+ if res.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to initialize sketch in container, response status code %d: %s", res.StatusCode, resBytes)
+ }
+ return nil
+}
+
+func findOrBuildDockerImage(ctx context.Context, stdout, stderr io.Writer, cwd, gitRoot, antURL, antAPIKey string, forceRebuild bool) (imgName string, err error) {
+ h := sha256.Sum256([]byte(gitRoot))
+ imgName = "sketch-" + hex.EncodeToString(h[:6])
+
+ var curImgInitFilesHash string
+ if out, err := combinedOutput(ctx, "docker", "inspect", "--format", "{{json .Config.Labels}}", imgName); err != nil {
+ if strings.Contains(string(out), "No such object") {
+ // Image does not exist, continue and build it.
+ curImgInitFilesHash = ""
+ } else {
+ return "", fmt.Errorf("docker inspect failed: %s, %v", out, err)
+ }
+ } else {
+ m := map[string]string{}
+ if err := json.Unmarshal(bytes.TrimSpace(out), &m); err != nil {
+ return "", fmt.Errorf("docker inspect output unparsable: %s, %v", out, err)
+ }
+ curImgInitFilesHash = m["sketch_context"]
+ }
+
+ candidates, err := findRepoDockerfiles(cwd, gitRoot)
+ if err != nil {
+ return "", fmt.Errorf("find dockerfile: %w", err)
+ }
+
+ var initFiles map[string]string
+ var dockerfilePath string
+
+ // TODO: prefer a "Dockerfile.sketch" so users can tailor any env to this tool.
+ if len(candidates) == 1 && strings.ToLower(filepath.Base(candidates[0])) == "dockerfile" {
+ dockerfilePath = candidates[0]
+ contents, err := os.ReadFile(dockerfilePath)
+ if err != nil {
+ return "", err
+ }
+ fmt.Printf("using %s as dev env\n", candidates[0])
+ if hashInitFiles(map[string]string{dockerfilePath: string(contents)}) == curImgInitFilesHash && !forceRebuild {
+ fmt.Printf("using existing docker image %s\n", imgName)
+ return imgName, nil
+ }
+ } else {
+ initFiles, err = readInitFiles(os.DirFS(gitRoot))
+ if err != nil {
+ return "", err
+ }
+ subPathWorkingDir, err := filepath.Rel(gitRoot, cwd)
+ if err != nil {
+ return "", err
+ }
+ initFileHash := hashInitFiles(initFiles)
+ if curImgInitFilesHash == initFileHash && !forceRebuild {
+ fmt.Printf("using existing docker image %s\n", imgName)
+ return imgName, nil
+ }
+
+ start := time.Now()
+ dockerfile, err := createDockerfile(ctx, http.DefaultClient, antURL, antAPIKey, initFiles, subPathWorkingDir)
+ if err != nil {
+ return "", fmt.Errorf("create dockerfile: %w", err)
+ }
+ dockerfilePath = filepath.Join(cwd, "tmp-sketch-dockerfile")
+ if err := os.WriteFile(dockerfilePath, []byte(dockerfile), 0o666); err != nil {
+ return "", err
+ }
+ defer os.Remove(dockerfilePath)
+
+ fmt.Fprintf(stderr, "generated Dockerfile in %s:\n\t%s\n\n", time.Since(start).Round(time.Millisecond), strings.Replace(dockerfile, "\n", "\n\t", -1))
+ }
+
+ var gitUserEmail, gitUserName string
+ if out, err := combinedOutput(ctx, "git", "config", "--get", "user.email"); err != nil {
+ return "", fmt.Errorf("git config: %s: %v", out, err)
+ } else {
+ gitUserEmail = strings.TrimSpace(string(out))
+ }
+ if out, err := combinedOutput(ctx, "git", "config", "--get", "user.name"); err != nil {
+ return "", fmt.Errorf("git config: %s: %v", out, err)
+ } else {
+ gitUserName = strings.TrimSpace(string(out))
+ }
+
+ start := time.Now()
+ cmd := exec.CommandContext(ctx,
+ "docker", "build",
+ "-t", imgName,
+ "-f", dockerfilePath,
+ "--build-arg", "GIT_USER_EMAIL="+gitUserEmail,
+ "--build-arg", "GIT_USER_NAME="+gitUserName,
+ ".",
+ )
+ cmd.Dir = gitRoot
+ cmd.Stdout = stdout
+ cmd.Stderr = stderr
+ fmt.Printf("building docker image %s...\n", imgName)
+
+ err = run(ctx, "docker build", cmd)
+ if err != nil {
+ return "", fmt.Errorf("docker build failed: %v", err)
+ }
+ fmt.Printf("built docker image %s in %s\n", imgName, time.Since(start).Round(time.Millisecond))
+ return imgName, nil
+}
+
+func findRepoDockerfiles(cwd, gitRoot string) ([]string, error) {
+ files, err := findDirDockerfiles(cwd)
+ if err != nil {
+ return nil, err
+ }
+ if len(files) > 0 {
+ return files, nil
+ }
+
+ path := cwd
+ for path != gitRoot {
+ path = filepath.Dir(path)
+ files, err := findDirDockerfiles(path)
+ if err != nil {
+ return nil, err
+ }
+ if len(files) > 0 {
+ return files, nil
+ }
+ }
+ return files, nil
+}
+
+// findDirDockerfiles finds all "Dockerfile*" files in a directory.
+func findDirDockerfiles(root string) (res []string, err error) {
+ err = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if info.IsDir() && root != path {
+ return filepath.SkipDir
+ }
+ name := strings.ToLower(info.Name())
+ if name == "dockerfile" || strings.HasPrefix(name, "dockerfile.") {
+ res = append(res, path)
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ return res, nil
+}
+
+func findGitRoot(ctx context.Context, path string) (string, error) {
+ cmd := exec.CommandContext(ctx, "git", "rev-parse", "--git-common-dir")
+ cmd.Dir = path
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ if strings.Contains(string(out), "not a git repository") {
+ return "", fmt.Errorf(`sketch needs to run from within a git repo, but %s is not part of a git repo.
+Consider one of the following options:
+ - cd to a different dir that is already part of a git repo first, or
+ - to create a new git repo from this directory (%s), run this command:
+
+ git init . && git commit --allow-empty -m "initial commit"
+
+and try running sketch again.
+`, path, path)
+ }
+ return "", fmt.Errorf("git rev-parse --git-common-dir: %s: %w", out, err)
+ }
+ gitDir := strings.TrimSpace(string(out)) // location of .git dir, often as a relative path
+ absGitDir := filepath.Join(path, gitDir)
+ return filepath.Dir(absGitDir), err
+}
+
+func OpenBrowser(ctx context.Context, url string) {
+ var cmd *exec.Cmd
+ switch runtime.GOOS {
+ case "darwin":
+ cmd = exec.CommandContext(ctx, "open", url)
+ case "windows":
+ cmd = exec.CommandContext(ctx, "cmd", "/c", "start", url)
+ default: // Linux and other Unix-like systems
+ cmd = exec.CommandContext(ctx, "xdg-open", url)
+ }
+ if b, err := cmd.CombinedOutput(); err != nil {
+ fmt.Fprintf(os.Stderr, "failed to open browser: %v: %s\n", err, b)
+ }
+}
diff --git a/dockerimg/dockerimg_test.go b/dockerimg/dockerimg_test.go
new file mode 100644
index 0000000..cef5173
--- /dev/null
+++ b/dockerimg/dockerimg_test.go
@@ -0,0 +1,193 @@
+package dockerimg
+
+import (
+ "context"
+ "flag"
+ "io/fs"
+ "net/http"
+ "os"
+ "strings"
+ "testing"
+ "testing/fstest"
+
+ "github.com/google/go-cmp/cmp"
+ "sketch.dev/httprr"
+)
+
+var flagRewriteWant = flag.Bool("rewritewant", false, "rewrite the dockerfiles we want from the model")
+
+func TestCreateDockerfile(t *testing.T) {
+ ctx := context.Background()
+
+ tests := []struct {
+ name string
+ fsys fs.FS
+ }{
+ {
+ name: "Basic repo with README",
+ fsys: fstest.MapFS{
+ "README.md": &fstest.MapFile{Data: []byte("# Test Project\nA Go project for testing.")},
+ },
+ },
+ {
+ // TODO: this looks bogus.
+ name: "Repo with README and workflow",
+ fsys: fstest.MapFS{
+ "README.md": &fstest.MapFile{Data: []byte("# Test Project\nA Go project for testing.")},
+ ".github/workflows/test.yml": &fstest.MapFile{Data: []byte(`name: Test
+on: [push]
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+ - uses: actions/setup-node@v3
+ with:
+ node-version: '18'
+ - name: Install and activate corepack
+ run: |
+ npm install -g corepack
+ corepack enable
+ - run: go test ./...`)},
+ },
+ },
+ {
+ name: "mention a devtool in the readme",
+ fsys: fstest.MapFS{
+ "readme.md": &fstest.MapFile{Data: []byte("# Test Project\nYou must install `dot` to run the tests.")},
+ },
+ },
+ {
+ name: "empty repo",
+ fsys: fstest.MapFS{
+ "main.go": &fstest.MapFile{Data: []byte("package main\n\nfunc main() {}")},
+ },
+ },
+ {
+ name: "python misery",
+ fsys: fstest.MapFS{
+ "README.md": &fstest.MapFile{Data: []byte("# Our amazing repo\n\nTo use this project you need python 3.11 and the dvc tool")},
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ basePath := "testdata/" + strings.ToLower(strings.Replace(t.Name(), "/", "_", -1))
+ rrPath := basePath + ".httprr"
+ rr, err := httprr.Open(rrPath, http.DefaultTransport)
+ if err != nil && !os.IsNotExist(err) {
+ t.Fatal(err)
+ }
+ rr.ScrubReq(func(req *http.Request) error {
+ req.Header.Del("x-api-key")
+ return nil
+ })
+ initFiles, err := readInitFiles(tt.fsys)
+ if err != nil {
+ t.Fatal(err)
+ }
+ result, err := createDockerfile(ctx, rr.Client(), "", os.Getenv("ANTHROPIC_API_KEY"), initFiles, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ wantPath := basePath + ".dockerfile"
+
+ if *flagRewriteWant {
+ if err := os.WriteFile(wantPath, []byte(result), 0o666); err != nil {
+ t.Fatal(err)
+ }
+ return
+ }
+
+ wantBytes, err := os.ReadFile(wantPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := string(wantBytes)
+ if diff := cmp.Diff(want, result); diff != "" {
+ t.Errorf("dockerfile does not match. got:\n----\n%s\n----\n\ndiff: %s", result, diff)
+ }
+ })
+ }
+}
+
+func TestReadInitFiles(t *testing.T) {
+ testFS := fstest.MapFS{
+ "README.md": &fstest.MapFile{Data: []byte("# Test Repo")},
+ ".github/workflows/test.yml": &fstest.MapFile{Data: []byte("name: Test Workflow")},
+ "main.go": &fstest.MapFile{Data: []byte("package main")},
+ ".git/HEAD": &fstest.MapFile{Data: []byte("ref: refs/heads/main")},
+ "random/README.md": &fstest.MapFile{Data: []byte("ignore me")},
+ }
+
+ files, err := readInitFiles(testFS)
+ if err != nil {
+ t.Fatalf("readInitFiles failed: %v", err)
+ }
+
+ // Should have 2 files: README.md and .github/workflows/test.yml
+ if len(files) != 2 {
+ t.Errorf("Expected 2 files, got %d", len(files))
+ }
+
+ if content, ok := files["README.md"]; !ok {
+ t.Error("README.md not found")
+ } else if content != "# Test Repo" {
+ t.Errorf("README.md has incorrect content: %q", content)
+ }
+
+ if content, ok := files[".github/workflows/test.yml"]; !ok {
+ t.Error(".github/workflows/test.yml not found")
+ } else if content != "name: Test Workflow" {
+ t.Errorf("Workflow file has incorrect content: %q", content)
+ }
+
+ if _, ok := files["main.go"]; ok {
+ t.Error("main.go should not be included")
+ }
+
+ if _, ok := files[".git/HEAD"]; ok {
+ t.Error(".git/HEAD should not be included")
+ }
+}
+
+func TestReadInitFilesWithSubdir(t *testing.T) {
+ // Create a file system with files in a subdirectory
+ testFS := fstest.MapFS{
+ "subdir/README.md": &fstest.MapFile{Data: []byte("# Test Repo")},
+ "subdir/.github/workflows/test.yml": &fstest.MapFile{Data: []byte("name: Test Workflow")},
+ "subdir/main.go": &fstest.MapFile{Data: []byte("package main")},
+ }
+
+ // Use fs.Sub to get a sub-filesystem
+ subFS, err := fs.Sub(testFS, "subdir")
+ if err != nil {
+ t.Fatalf("fs.Sub failed: %v", err)
+ }
+
+ files, err := readInitFiles(subFS)
+ if err != nil {
+ t.Fatalf("readInitFiles failed: %v", err)
+ }
+
+ // Should have 2 files: README.md and .github/workflows/test.yml
+ if len(files) != 2 {
+ t.Errorf("Expected 2 files, got %d", len(files))
+ }
+
+ // Verify README.md was found
+ if content, ok := files["README.md"]; !ok {
+ t.Error("README.md not found")
+ } else if content != "# Test Repo" {
+ t.Errorf("README.md has incorrect content: %q", content)
+ }
+
+ // Verify workflow file was found
+ if content, ok := files[".github/workflows/test.yml"]; !ok {
+ t.Error(".github/workflows/test.yml not found")
+ } else if content != "name: Test Workflow" {
+ t.Errorf("Workflow file has incorrect content: %q", content)
+ }
+}
diff --git a/dockerimg/githttp.go b/dockerimg/githttp.go
new file mode 100644
index 0000000..d618ab6
--- /dev/null
+++ b/dockerimg/githttp.go
@@ -0,0 +1,53 @@
+package dockerimg
+
+import (
+ "fmt"
+ "log/slog"
+ "net/http"
+ "net/http/cgi"
+ "os/exec"
+ "strings"
+)
+
+type gitHTTP struct {
+ gitRepoRoot string
+}
+
+func (g *gitHTTP) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ defer func() {
+ if err := recover(); err != nil {
+ slog.ErrorContext(r.Context(), "gitHTTP.ServeHTTP panic", slog.Any("recovered_err", err))
+
+ // Return an error response to the client
+ http.Error(w, fmt.Sprintf("panic: %v\n", err), http.StatusInternalServerError)
+ }
+ }()
+ if !strings.HasPrefix(r.RemoteAddr, "127.0.0.1:") {
+ slog.InfoContext(r.Context(), "githttp: denied", "remote addr", r.RemoteAddr)
+ http.Error(w, "no", http.StatusUnauthorized)
+ return
+ }
+ gitBin, err := exec.LookPath("git")
+ if err != nil {
+ http.Error(w, "no git: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Cache-Control", "no-cache")
+ h := &cgi.Handler{
+ Path: gitBin,
+ Args: []string{"http-backend"},
+ Dir: g.gitRepoRoot,
+ Env: []string{
+ "GIT_PROJECT_ROOT=" + g.gitRepoRoot,
+ "PATH_INFO=" + r.URL.Path,
+ "QUERY_STRING=" + r.URL.RawQuery,
+ "REQUEST_METHOD=" + r.Method,
+ "GIT_HTTP_EXPORT_ALL=true",
+ "GIT_HTTP_ALLOW_REPACK=true",
+ "GIT_HTTP_ALLOW_PUSH=true",
+ "GIT_HTTP_VERBOSE=1",
+ },
+ }
+ h.ServeHTTP(w, r)
+}
diff --git a/dockerimg/testdata/dockerfile_convo.httprr b/dockerimg/testdata/dockerfile_convo.httprr
new file mode 100644
index 0000000..0470323
--- /dev/null
+++ b/dockerimg/testdata/dockerfile_convo.httprr
@@ -0,0 +1,30 @@
+HTTP/1.1 200 OK
+Date: Wed, 05 Mar 2025 10:00:00 GMT
+Content-Type: application/json
+Content-Length: 987
+
+{
+ "id": "msg_0123456789abcdef",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-3-opus-20240229",
+ "stop_reason": "tool_use",
+ "stop_sequence": null,
+ "usage": {
+ "input_tokens": 256,
+ "output_tokens": 180
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "I'll create a Dockerfile for this Go project."
+ }
+ ],
+ "tool_use": {
+ "id": "tu_0123456789abcdef",
+ "name": "dockerfile",
+ "input": {
+ "contents": "FROM golang:1.21-alpine\n\nWORKDIR /app\n\n# Install git for dependencies\nRUN apk add --no-cache git\n\n# Pre-copy/cache go.mod for efficient Docker caching\nCOPY go.mod go.sum* ./\nRUN go mod download\n\n# Copy the source code\nCOPY . .\n\n# Build the application\nRUN go build -o app ./cmd/sketch\n\n# Environment setup\nENV CGO_ENABLED=0\n\nCMD [\"./app\"]"
+ }
+ }
+}
diff --git a/dockerimg/testdata/testcreatedockerfile_basic_repo_with_readme.dockerfile b/dockerimg/testdata/testcreatedockerfile_basic_repo_with_readme.dockerfile
new file mode 100644
index 0000000..128c852
--- /dev/null
+++ b/dockerimg/testdata/testcreatedockerfile_basic_repo_with_readme.dockerfile
@@ -0,0 +1,27 @@
+FROM golang:1.24.2-alpine3.21
+
+RUN apk add bash git make jq sqlite gcc musl-dev linux-headers npm nodejs go github-cli ripgrep fzf
+
+ENV GOTOOLCHAIN=auto
+ENV GOPATH=/go
+ENV PATH="$GOPATH/bin:$PATH"
+
+RUN go install golang.org/x/tools/cmd/goimports@latest
+RUN go install golang.org/x/tools/gopls@latest
+RUN go install mvdan.cc/gofumpt@latest
+
+RUN apk add --no-cache curl || true
+
+ARG GIT_USER_EMAIL
+ARG GIT_USER_NAME
+
+RUN git config --global user.email "$GIT_USER_EMAIL" && \
+ git config --global user.name "$GIT_USER_NAME"
+
+LABEL sketch_context="72646c3fcb61b4bb1017a3b5b76d5b2126e9f563ce7da5d58a379539595f0344"
+COPY . /app
+
+WORKDIR /app
+RUN if [ -f go.mod ]; then go mod download; fi
+
+CMD ["/bin/sketch"]
\ No newline at end of file
diff --git a/dockerimg/testdata/testcreatedockerfile_basic_repo_with_readme.httprr b/dockerimg/testdata/testcreatedockerfile_basic_repo_with_readme.httprr
new file mode 100644
index 0000000..1c3ee06
--- /dev/null
+++ b/dockerimg/testdata/testcreatedockerfile_basic_repo_with_readme.httprr
@@ -0,0 +1,81 @@
+httprr trace v1
+3037 1681
+POST https://api.anthropic.com/v1/messages HTTP/1.1
+Host: api.anthropic.com
+User-Agent: Go-http-client/1.1
+Content-Length: 2840
+Anthropic-Version: 2023-06-01
+Content-Type: application/json
+
+{
+ "model": "claude-3-7-sonnet-20250219",
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "text",
+ "text": "\nCall the dockerfile tool to create a Dockerfile.\nThe parameters to dockerfile fill out the From and ExtraCmds\ntemplate variables in the following Go template:\n\n```\nFROM {{.From}}\n\nRUN apk add bash git make jq sqlite gcc musl-dev linux-headers npm nodejs go github-cli ripgrep fzf\n\nENV GOTOOLCHAIN=auto\nENV GOPATH=/go\nENV PATH=\"$GOPATH/bin:$PATH\"\n\nRUN go install golang.org/x/tools/cmd/goimports@latest\nRUN go install golang.org/x/tools/gopls@latest\nRUN go install mvdan.cc/gofumpt@latest\n\n{{.ExtraCmds}}\n\nARG GIT_USER_EMAIL\nARG GIT_USER_NAME\n\nRUN git config --global user.email \"$GIT_USER_EMAIL\" \u0026\u0026 \\\n git config --global user.name \"$GIT_USER_NAME\"\n\nLABEL sketch_context=\"{{.InitFilesHash}}\"\nCOPY . /app\n\nWORKDIR /app{{.SubDir}}\nRUN if [ -f go.mod ]; then go mod download; fi\n\nCMD [\"/bin/sketch\"]\n```\n\nIn particular:\n- Assume it is primarily a Go project. For a minimal env, prefer 1.24.2-alpine3.21 as a base image.\n- If any python is needed at all, switch to using a python alpine image as a the base and apk add go.\n Favor using uv, and use one of these base images, depending on the preferred python version:\n ghcr.io/astral-sh/uv:python3.13-alpine\n ghcr.io/astral-sh/uv:python3.12-alpine\n ghcr.io/astral-sh/uv:python3.11-alpine\n- When using pip to install packages, use: uv pip install --system.\n- Python env setup is challenging and often no required, so any RUN commands involving python tooling should be written to let docker build continue if there is a failure.\n- Include any tools particular to this repository that can be inferred from the given context.\n- Append || true to any apk add commands in case the package does not exist.\n- Do not expose any ports.\nHere is the content of several files from the repository that may be relevant:\n\n"
+ },
+ {
+ "type": "text",
+ "text": "Here is the contents README.md:\n\u003cfile\u003e\n# Test Project\nA Go project for testing.\n\u003c/file\u003e\n\n"
+ },
+ {
+ "type": "text",
+ "text": "Now call the dockerfile tool.",
+ "cache_control": {
+ "type": "ephemeral"
+ }
+ }
+ ]
+ }
+ ],
+ "max_tokens": 8192,
+ "tools": [
+ {
+ "name": "dockerfile",
+ "description": "Helps define a Dockerfile that sets up a dev environment for this project.",
+ "input_schema": {
+ "type": "object",
+ "required": [
+ "from",
+ "extra_cmds"
+ ],
+ "properties": {
+ "from": {
+ "type": "string",
+ "description": "The alpine base image provided to the dockerfile FROM command"
+ },
+ "extra_cmds": {
+ "type": "string",
+ "description": "Extra commands to add to the dockerfile."
+ }
+ }
+ }
+ }
+ ]
+}HTTP/2.0 200 OK
+Anthropic-Organization-Id: 3c473a21-7208-450a-a9f8-80aebda45c1b
+Anthropic-Ratelimit-Input-Tokens-Limit: 200000
+Anthropic-Ratelimit-Input-Tokens-Remaining: 200000
+Anthropic-Ratelimit-Input-Tokens-Reset: 2025-04-05T00:17:28Z
+Anthropic-Ratelimit-Output-Tokens-Limit: 80000
+Anthropic-Ratelimit-Output-Tokens-Remaining: 80000
+Anthropic-Ratelimit-Output-Tokens-Reset: 2025-04-05T00:17:30Z
+Anthropic-Ratelimit-Requests-Limit: 4000
+Anthropic-Ratelimit-Requests-Remaining: 3999
+Anthropic-Ratelimit-Requests-Reset: 2025-04-05T00:17:28Z
+Anthropic-Ratelimit-Tokens-Limit: 280000
+Anthropic-Ratelimit-Tokens-Remaining: 280000
+Anthropic-Ratelimit-Tokens-Reset: 2025-04-05T00:17:28Z
+Cf-Cache-Status: DYNAMIC
+Cf-Ray: 92b4dcd6de42943a-SJC
+Content-Type: application/json
+Date: Sat, 05 Apr 2025 00:17:30 GMT
+Request-Id: req_01ABvmpoBii9QTziayoGGv2a
+Server: cloudflare
+Via: 1.1 google
+X-Robots-Tag: none
+
+{"id":"msg_01TCvHPyXog5ysYCPaETR9JD","type":"message","role":"assistant","model":"claude-3-7-sonnet-20250219","content":[{"type":"text","text":"I'll help you create a Dockerfile for this Go project.\n\nBased on the information provided, this appears to be a straightforward Go project without any Python requirements. The README confirms it's \"A Go project for testing.\" Therefore, I'll use the Go alpine image as the base."},{"type":"tool_use","id":"toolu_01MYYXLM3H8esGKeDGsjcrSY","name":"dockerfile","input":{"from":"golang:1.24.2-alpine3.21","extra_cmds":"RUN apk add --no-cache curl || true"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":1014,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":153}}
\ No newline at end of file
diff --git a/dockerimg/testdata/testcreatedockerfile_empty_repo.dockerfile b/dockerimg/testdata/testcreatedockerfile_empty_repo.dockerfile
new file mode 100644
index 0000000..a346cd3
--- /dev/null
+++ b/dockerimg/testdata/testcreatedockerfile_empty_repo.dockerfile
@@ -0,0 +1,27 @@
+FROM golang:1.24.2-alpine3.21
+
+RUN apk add bash git make jq sqlite gcc musl-dev linux-headers npm nodejs go github-cli ripgrep fzf
+
+ENV GOTOOLCHAIN=auto
+ENV GOPATH=/go
+ENV PATH="$GOPATH/bin:$PATH"
+
+RUN go install golang.org/x/tools/cmd/goimports@latest
+RUN go install golang.org/x/tools/gopls@latest
+RUN go install mvdan.cc/gofumpt@latest
+
+RUN echo "Go development environment ready"
+
+ARG GIT_USER_EMAIL
+ARG GIT_USER_NAME
+
+RUN git config --global user.email "$GIT_USER_EMAIL" && \
+ git config --global user.name "$GIT_USER_NAME"
+
+LABEL sketch_context="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+COPY . /app
+
+WORKDIR /app
+RUN if [ -f go.mod ]; then go mod download; fi
+
+CMD ["/bin/sketch"]
\ No newline at end of file
diff --git a/dockerimg/testdata/testcreatedockerfile_empty_repo.httprr b/dockerimg/testdata/testcreatedockerfile_empty_repo.httprr
new file mode 100644
index 0000000..abc3dbf
--- /dev/null
+++ b/dockerimg/testdata/testcreatedockerfile_empty_repo.httprr
@@ -0,0 +1,77 @@
+httprr trace v1
+2790 1642
+POST https://api.anthropic.com/v1/messages HTTP/1.1
+Host: api.anthropic.com
+User-Agent: Go-http-client/1.1
+Content-Length: 2593
+Anthropic-Version: 2023-06-01
+Content-Type: application/json
+
+{
+ "model": "claude-3-7-sonnet-20250219",
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "text",
+ "text": "\nCall the dockerfile tool to create a Dockerfile.\nThe parameters to dockerfile fill out the From and ExtraCmds\ntemplate variables in the following Go template:\n\n```\nFROM {{.From}}\n\nRUN apk add bash git make jq sqlite gcc musl-dev linux-headers npm nodejs go github-cli ripgrep fzf\n\nENV GOTOOLCHAIN=auto\nENV GOPATH=/go\nENV PATH=\"$GOPATH/bin:$PATH\"\n\nRUN go install golang.org/x/tools/cmd/goimports@latest\nRUN go install golang.org/x/tools/gopls@latest\nRUN go install mvdan.cc/gofumpt@latest\n\n{{.ExtraCmds}}\n\nARG GIT_USER_EMAIL\nARG GIT_USER_NAME\n\nRUN git config --global user.email \"$GIT_USER_EMAIL\" \u0026\u0026 \\\n git config --global user.name \"$GIT_USER_NAME\"\n\nLABEL sketch_context=\"{{.InitFilesHash}}\"\nCOPY . /app\n\nWORKDIR /app{{.SubDir}}\nRUN if [ -f go.mod ]; then go mod download; fi\n\nCMD [\"/bin/sketch\"]\n```\n\nIn particular:\n- Assume it is primarily a Go project. For a minimal env, prefer 1.24.2-alpine3.21 as a base image.\n- If any python is needed at all, switch to using a python alpine image as a the base and apk add go.\n Favor using uv, and use one of these base images, depending on the preferred python version:\n ghcr.io/astral-sh/uv:python3.13-alpine\n ghcr.io/astral-sh/uv:python3.12-alpine\n ghcr.io/astral-sh/uv:python3.11-alpine\n- When using pip to install packages, use: uv pip install --system.\n- Python env setup is challenging and often no required, so any RUN commands involving python tooling should be written to let docker build continue if there is a failure.\n- Include any tools particular to this repository that can be inferred from the given context.\n- Append || true to any apk add commands in case the package does not exist.\n- Do not expose any ports.\n"
+ },
+ {
+ "type": "text",
+ "text": "Now call the dockerfile tool.",
+ "cache_control": {
+ "type": "ephemeral"
+ }
+ }
+ ]
+ }
+ ],
+ "max_tokens": 8192,
+ "tools": [
+ {
+ "name": "dockerfile",
+ "description": "Helps define a Dockerfile that sets up a dev environment for this project.",
+ "input_schema": {
+ "type": "object",
+ "required": [
+ "from",
+ "extra_cmds"
+ ],
+ "properties": {
+ "from": {
+ "type": "string",
+ "description": "The alpine base image provided to the dockerfile FROM command"
+ },
+ "extra_cmds": {
+ "type": "string",
+ "description": "Extra commands to add to the dockerfile."
+ }
+ }
+ }
+ }
+ ]
+}HTTP/2.0 200 OK
+Anthropic-Organization-Id: 3c473a21-7208-450a-a9f8-80aebda45c1b
+Anthropic-Ratelimit-Input-Tokens-Limit: 200000
+Anthropic-Ratelimit-Input-Tokens-Remaining: 200000
+Anthropic-Ratelimit-Input-Tokens-Reset: 2025-04-05T00:17:37Z
+Anthropic-Ratelimit-Output-Tokens-Limit: 80000
+Anthropic-Ratelimit-Output-Tokens-Remaining: 80000
+Anthropic-Ratelimit-Output-Tokens-Reset: 2025-04-05T00:17:39Z
+Anthropic-Ratelimit-Requests-Limit: 4000
+Anthropic-Ratelimit-Requests-Remaining: 3999
+Anthropic-Ratelimit-Requests-Reset: 2025-04-05T00:17:36Z
+Anthropic-Ratelimit-Tokens-Limit: 280000
+Anthropic-Ratelimit-Tokens-Remaining: 280000
+Anthropic-Ratelimit-Tokens-Reset: 2025-04-05T00:17:37Z
+Cf-Cache-Status: DYNAMIC
+Cf-Ray: 92b4dd0b09c6943a-SJC
+Content-Type: application/json
+Date: Sat, 05 Apr 2025 00:17:39 GMT
+Request-Id: req_01CT8x3PBEoaYUopedMVuEqp
+Server: cloudflare
+Via: 1.1 google
+X-Robots-Tag: none
+
+{"id":"msg_01SFTkFTza8Sagk4GWC5g1SJ","type":"message","role":"assistant","model":"claude-3-7-sonnet-20250219","content":[{"type":"text","text":"I'll help you create a Dockerfile using the dockerfile tool. Based on your requirements, I'll use the Go 1.24.2-alpine3.21 base image since you indicated it's primarily a Go project and no specific Python requirements were mentioned."},{"type":"tool_use","id":"toolu_01EN6SxM4w1B9onGtpLGnwNF","name":"dockerfile","input":{"from":"golang:1.24.2-alpine3.21","extra_cmds":"RUN echo \"Go development environment ready\""}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":970,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":145}}
\ No newline at end of file
diff --git a/dockerimg/testdata/testcreatedockerfile_mention_a_devtool_in_the_readme.dockerfile b/dockerimg/testdata/testcreatedockerfile_mention_a_devtool_in_the_readme.dockerfile
new file mode 100644
index 0000000..c1a8bd6
--- /dev/null
+++ b/dockerimg/testdata/testcreatedockerfile_mention_a_devtool_in_the_readme.dockerfile
@@ -0,0 +1,27 @@
+FROM golang:1.24.2-alpine3.21
+
+RUN apk add bash git make jq sqlite gcc musl-dev linux-headers npm nodejs go github-cli ripgrep fzf
+
+ENV GOTOOLCHAIN=auto
+ENV GOPATH=/go
+ENV PATH="$GOPATH/bin:$PATH"
+
+RUN go install golang.org/x/tools/cmd/goimports@latest
+RUN go install golang.org/x/tools/gopls@latest
+RUN go install mvdan.cc/gofumpt@latest
+
+RUN apk add graphviz || true
+
+ARG GIT_USER_EMAIL
+ARG GIT_USER_NAME
+
+RUN git config --global user.email "$GIT_USER_EMAIL" && \
+ git config --global user.name "$GIT_USER_NAME"
+
+LABEL sketch_context="077dd6d8e701af79c72b77ea9f851278a82eb35ea0c63e1999a2ef78272ce284"
+COPY . /app
+
+WORKDIR /app
+RUN if [ -f go.mod ]; then go mod download; fi
+
+CMD ["/bin/sketch"]
\ No newline at end of file
diff --git a/dockerimg/testdata/testcreatedockerfile_mention_a_devtool_in_the_readme.httprr b/dockerimg/testdata/testcreatedockerfile_mention_a_devtool_in_the_readme.httprr
new file mode 100644
index 0000000..f4542e9
--- /dev/null
+++ b/dockerimg/testdata/testcreatedockerfile_mention_a_devtool_in_the_readme.httprr
@@ -0,0 +1,81 @@
+httprr trace v1
+3052 1634
+POST https://api.anthropic.com/v1/messages HTTP/1.1
+Host: api.anthropic.com
+User-Agent: Go-http-client/1.1
+Content-Length: 2855
+Anthropic-Version: 2023-06-01
+Content-Type: application/json
+
+{
+ "model": "claude-3-7-sonnet-20250219",
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "text",
+ "text": "\nCall the dockerfile tool to create a Dockerfile.\nThe parameters to dockerfile fill out the From and ExtraCmds\ntemplate variables in the following Go template:\n\n```\nFROM {{.From}}\n\nRUN apk add bash git make jq sqlite gcc musl-dev linux-headers npm nodejs go github-cli ripgrep fzf\n\nENV GOTOOLCHAIN=auto\nENV GOPATH=/go\nENV PATH=\"$GOPATH/bin:$PATH\"\n\nRUN go install golang.org/x/tools/cmd/goimports@latest\nRUN go install golang.org/x/tools/gopls@latest\nRUN go install mvdan.cc/gofumpt@latest\n\n{{.ExtraCmds}}\n\nARG GIT_USER_EMAIL\nARG GIT_USER_NAME\n\nRUN git config --global user.email \"$GIT_USER_EMAIL\" \u0026\u0026 \\\n git config --global user.name \"$GIT_USER_NAME\"\n\nLABEL sketch_context=\"{{.InitFilesHash}}\"\nCOPY . /app\n\nWORKDIR /app{{.SubDir}}\nRUN if [ -f go.mod ]; then go mod download; fi\n\nCMD [\"/bin/sketch\"]\n```\n\nIn particular:\n- Assume it is primarily a Go project. For a minimal env, prefer 1.24.2-alpine3.21 as a base image.\n- If any python is needed at all, switch to using a python alpine image as a the base and apk add go.\n Favor using uv, and use one of these base images, depending on the preferred python version:\n ghcr.io/astral-sh/uv:python3.13-alpine\n ghcr.io/astral-sh/uv:python3.12-alpine\n ghcr.io/astral-sh/uv:python3.11-alpine\n- When using pip to install packages, use: uv pip install --system.\n- Python env setup is challenging and often no required, so any RUN commands involving python tooling should be written to let docker build continue if there is a failure.\n- Include any tools particular to this repository that can be inferred from the given context.\n- Append || true to any apk add commands in case the package does not exist.\n- Do not expose any ports.\nHere is the content of several files from the repository that may be relevant:\n\n"
+ },
+ {
+ "type": "text",
+ "text": "Here is the contents readme.md:\n\u003cfile\u003e\n# Test Project\nYou must install `dot` to run the tests.\n\u003c/file\u003e\n\n"
+ },
+ {
+ "type": "text",
+ "text": "Now call the dockerfile tool.",
+ "cache_control": {
+ "type": "ephemeral"
+ }
+ }
+ ]
+ }
+ ],
+ "max_tokens": 8192,
+ "tools": [
+ {
+ "name": "dockerfile",
+ "description": "Helps define a Dockerfile that sets up a dev environment for this project.",
+ "input_schema": {
+ "type": "object",
+ "required": [
+ "from",
+ "extra_cmds"
+ ],
+ "properties": {
+ "from": {
+ "type": "string",
+ "description": "The alpine base image provided to the dockerfile FROM command"
+ },
+ "extra_cmds": {
+ "type": "string",
+ "description": "Extra commands to add to the dockerfile."
+ }
+ }
+ }
+ }
+ ]
+}HTTP/2.0 200 OK
+Anthropic-Organization-Id: 3c473a21-7208-450a-a9f8-80aebda45c1b
+Anthropic-Ratelimit-Input-Tokens-Limit: 200000
+Anthropic-Ratelimit-Input-Tokens-Remaining: 200000
+Anthropic-Ratelimit-Input-Tokens-Reset: 2025-04-05T00:17:34Z
+Anthropic-Ratelimit-Output-Tokens-Limit: 80000
+Anthropic-Ratelimit-Output-Tokens-Remaining: 80000
+Anthropic-Ratelimit-Output-Tokens-Reset: 2025-04-05T00:17:36Z
+Anthropic-Ratelimit-Requests-Limit: 4000
+Anthropic-Ratelimit-Requests-Remaining: 3999
+Anthropic-Ratelimit-Requests-Reset: 2025-04-05T00:17:33Z
+Anthropic-Ratelimit-Tokens-Limit: 280000
+Anthropic-Ratelimit-Tokens-Remaining: 280000
+Anthropic-Ratelimit-Tokens-Reset: 2025-04-05T00:17:34Z
+Cf-Cache-Status: DYNAMIC
+Cf-Ray: 92b4dcf9cae5943a-SJC
+Content-Type: application/json
+Date: Sat, 05 Apr 2025 00:17:36 GMT
+Request-Id: req_018EvH3HMhPpXWH9gksCthDn
+Server: cloudflare
+Via: 1.1 google
+X-Robots-Tag: none
+
+{"id":"msg_0178hjcqP4yFcpvD8FSm9LRp","type":"message","role":"assistant","model":"claude-3-7-sonnet-20250219","content":[{"type":"text","text":"Based on the information you've provided, I'll create a Dockerfile for your Go project. From the readme.md, I can see that the project requires the `dot` command, which is part of the Graphviz package, so I'll include this in the Dockerfile."},{"type":"tool_use","id":"toolu_01AGSztHxramwDkeKSevXYHz","name":"dockerfile","input":{"from":"golang:1.24.2-alpine3.21","extra_cmds":"RUN apk add graphviz || true"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":1019,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":151}}
\ No newline at end of file
diff --git a/dockerimg/testdata/testcreatedockerfile_python_misery.dockerfile b/dockerimg/testdata/testcreatedockerfile_python_misery.dockerfile
new file mode 100644
index 0000000..c27fb1a
--- /dev/null
+++ b/dockerimg/testdata/testcreatedockerfile_python_misery.dockerfile
@@ -0,0 +1,33 @@
+FROM ghcr.io/astral-sh/uv:python3.11-alpine
+
+RUN apk add bash git make jq sqlite gcc musl-dev linux-headers npm nodejs go github-cli ripgrep fzf
+
+ENV GOTOOLCHAIN=auto
+ENV GOPATH=/go
+ENV PATH="$GOPATH/bin:$PATH"
+
+RUN go install golang.org/x/tools/cmd/goimports@latest
+RUN go install golang.org/x/tools/gopls@latest
+RUN go install mvdan.cc/gofumpt@latest
+
+RUN apk add go || true
+
+# Install DVC (Data Version Control)
+RUN uv pip install --system dvc || true
+
+# Additional Python setup
+RUN uv pip install --system pytest pytest-cov || true
+
+ARG GIT_USER_EMAIL
+ARG GIT_USER_NAME
+
+RUN git config --global user.email "$GIT_USER_EMAIL" && \
+ git config --global user.name "$GIT_USER_NAME"
+
+LABEL sketch_context="5908dbf564085457e184c617549809359247c4f6e45aa8789e94122cecd538fb"
+COPY . /app
+
+WORKDIR /app
+RUN if [ -f go.mod ]; then go mod download; fi
+
+CMD ["/bin/sketch"]
\ No newline at end of file
diff --git a/dockerimg/testdata/testcreatedockerfile_python_misery.httprr b/dockerimg/testdata/testcreatedockerfile_python_misery.httprr
new file mode 100644
index 0000000..4498293
--- /dev/null
+++ b/dockerimg/testdata/testcreatedockerfile_python_misery.httprr
@@ -0,0 +1,81 @@
+httprr trace v1
+3075 1793
+POST https://api.anthropic.com/v1/messages HTTP/1.1
+Host: api.anthropic.com
+User-Agent: Go-http-client/1.1
+Content-Length: 2878
+Anthropic-Version: 2023-06-01
+Content-Type: application/json
+
+{
+ "model": "claude-3-7-sonnet-20250219",
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "text",
+ "text": "\nCall the dockerfile tool to create a Dockerfile.\nThe parameters to dockerfile fill out the From and ExtraCmds\ntemplate variables in the following Go template:\n\n```\nFROM {{.From}}\n\nRUN apk add bash git make jq sqlite gcc musl-dev linux-headers npm nodejs go github-cli ripgrep fzf\n\nENV GOTOOLCHAIN=auto\nENV GOPATH=/go\nENV PATH=\"$GOPATH/bin:$PATH\"\n\nRUN go install golang.org/x/tools/cmd/goimports@latest\nRUN go install golang.org/x/tools/gopls@latest\nRUN go install mvdan.cc/gofumpt@latest\n\n{{.ExtraCmds}}\n\nARG GIT_USER_EMAIL\nARG GIT_USER_NAME\n\nRUN git config --global user.email \"$GIT_USER_EMAIL\" \u0026\u0026 \\\n git config --global user.name \"$GIT_USER_NAME\"\n\nLABEL sketch_context=\"{{.InitFilesHash}}\"\nCOPY . /app\n\nWORKDIR /app{{.SubDir}}\nRUN if [ -f go.mod ]; then go mod download; fi\n\nCMD [\"/bin/sketch\"]\n```\n\nIn particular:\n- Assume it is primarily a Go project. For a minimal env, prefer 1.24.2-alpine3.21 as a base image.\n- If any python is needed at all, switch to using a python alpine image as a the base and apk add go.\n Favor using uv, and use one of these base images, depending on the preferred python version:\n ghcr.io/astral-sh/uv:python3.13-alpine\n ghcr.io/astral-sh/uv:python3.12-alpine\n ghcr.io/astral-sh/uv:python3.11-alpine\n- When using pip to install packages, use: uv pip install --system.\n- Python env setup is challenging and often no required, so any RUN commands involving python tooling should be written to let docker build continue if there is a failure.\n- Include any tools particular to this repository that can be inferred from the given context.\n- Append || true to any apk add commands in case the package does not exist.\n- Do not expose any ports.\nHere is the content of several files from the repository that may be relevant:\n\n"
+ },
+ {
+ "type": "text",
+ "text": "Here is the contents README.md:\n\u003cfile\u003e\n# Our amazing repo\n\nTo use this project you need python 3.11 and the dvc tool\n\u003c/file\u003e\n\n"
+ },
+ {
+ "type": "text",
+ "text": "Now call the dockerfile tool.",
+ "cache_control": {
+ "type": "ephemeral"
+ }
+ }
+ ]
+ }
+ ],
+ "max_tokens": 8192,
+ "tools": [
+ {
+ "name": "dockerfile",
+ "description": "Helps define a Dockerfile that sets up a dev environment for this project.",
+ "input_schema": {
+ "type": "object",
+ "required": [
+ "from",
+ "extra_cmds"
+ ],
+ "properties": {
+ "from": {
+ "type": "string",
+ "description": "The alpine base image provided to the dockerfile FROM command"
+ },
+ "extra_cmds": {
+ "type": "string",
+ "description": "Extra commands to add to the dockerfile."
+ }
+ }
+ }
+ }
+ ]
+}HTTP/2.0 200 OK
+Anthropic-Organization-Id: 3c473a21-7208-450a-a9f8-80aebda45c1b
+Anthropic-Ratelimit-Input-Tokens-Limit: 200000
+Anthropic-Ratelimit-Input-Tokens-Remaining: 200000
+Anthropic-Ratelimit-Input-Tokens-Reset: 2025-04-05T00:17:39Z
+Anthropic-Ratelimit-Output-Tokens-Limit: 80000
+Anthropic-Ratelimit-Output-Tokens-Remaining: 80000
+Anthropic-Ratelimit-Output-Tokens-Reset: 2025-04-05T00:17:42Z
+Anthropic-Ratelimit-Requests-Limit: 4000
+Anthropic-Ratelimit-Requests-Remaining: 3999
+Anthropic-Ratelimit-Requests-Reset: 2025-04-05T00:17:39Z
+Anthropic-Ratelimit-Tokens-Limit: 280000
+Anthropic-Ratelimit-Tokens-Remaining: 280000
+Anthropic-Ratelimit-Tokens-Reset: 2025-04-05T00:17:39Z
+Cf-Cache-Status: DYNAMIC
+Cf-Ray: 92b4dd1b7f3d943a-SJC
+Content-Type: application/json
+Date: Sat, 05 Apr 2025 00:17:42 GMT
+Request-Id: req_01TzHQukmmrk6cTHsyxuMSuZ
+Server: cloudflare
+Via: 1.1 google
+X-Robots-Tag: none
+
+{"id":"msg_01DVLCsdrDvzRHv7221La4Mw","type":"message","role":"assistant","model":"claude-3-7-sonnet-20250219","content":[{"type":"text","text":"Based on the README.md file, I can see that this project requires Python 3.11 and the DVC tool. Given this information, I'll create a Dockerfile that uses a Python 3.11 Alpine image as the base and includes the necessary tools."},{"type":"tool_use","id":"toolu_012FnmKYSqbhwr4xkAySuwgV","name":"dockerfile","input":{"from":"ghcr.io/astral-sh/uv:python3.11-alpine","extra_cmds":"RUN apk add go || true\n\n# Install DVC (Data Version Control)\nRUN uv pip install --system dvc || true\n\n# Additional Python setup\nRUN uv pip install --system pytest pytest-cov || true"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":1026,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":199}}
\ No newline at end of file
diff --git a/dockerimg/testdata/testcreatedockerfile_repo_with_readme_and_workflow.dockerfile b/dockerimg/testdata/testcreatedockerfile_repo_with_readme_and_workflow.dockerfile
new file mode 100644
index 0000000..a6acdb7
--- /dev/null
+++ b/dockerimg/testdata/testcreatedockerfile_repo_with_readme_and_workflow.dockerfile
@@ -0,0 +1,28 @@
+FROM golang:1.24.2-alpine3.21
+
+RUN apk add bash git make jq sqlite gcc musl-dev linux-headers npm nodejs go github-cli ripgrep fzf
+
+ENV GOTOOLCHAIN=auto
+ENV GOPATH=/go
+ENV PATH="$GOPATH/bin:$PATH"
+
+RUN go install golang.org/x/tools/cmd/goimports@latest
+RUN go install golang.org/x/tools/gopls@latest
+RUN go install mvdan.cc/gofumpt@latest
+
+RUN npm install -g corepack && \
+ corepack enable || true
+
+ARG GIT_USER_EMAIL
+ARG GIT_USER_NAME
+
+RUN git config --global user.email "$GIT_USER_EMAIL" && \
+ git config --global user.name "$GIT_USER_NAME"
+
+LABEL sketch_context="b38624baa0989968c26bf1a8b3f1c322e80ae65fa51c57743bd34eac2d5e0529"
+COPY . /app
+
+WORKDIR /app
+RUN if [ -f go.mod ]; then go mod download; fi
+
+CMD ["/bin/sketch"]
\ No newline at end of file
diff --git a/dockerimg/testdata/testcreatedockerfile_repo_with_readme_and_workflow.httprr b/dockerimg/testdata/testcreatedockerfile_repo_with_readme_and_workflow.httprr
new file mode 100644
index 0000000..32887bf
--- /dev/null
+++ b/dockerimg/testdata/testcreatedockerfile_repo_with_readme_and_workflow.httprr
@@ -0,0 +1,85 @@
+httprr trace v1
+3524 1724
+POST https://api.anthropic.com/v1/messages HTTP/1.1
+Host: api.anthropic.com
+User-Agent: Go-http-client/1.1
+Content-Length: 3327
+Anthropic-Version: 2023-06-01
+Content-Type: application/json
+
+{
+ "model": "claude-3-7-sonnet-20250219",
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "text",
+ "text": "\nCall the dockerfile tool to create a Dockerfile.\nThe parameters to dockerfile fill out the From and ExtraCmds\ntemplate variables in the following Go template:\n\n```\nFROM {{.From}}\n\nRUN apk add bash git make jq sqlite gcc musl-dev linux-headers npm nodejs go github-cli ripgrep fzf\n\nENV GOTOOLCHAIN=auto\nENV GOPATH=/go\nENV PATH=\"$GOPATH/bin:$PATH\"\n\nRUN go install golang.org/x/tools/cmd/goimports@latest\nRUN go install golang.org/x/tools/gopls@latest\nRUN go install mvdan.cc/gofumpt@latest\n\n{{.ExtraCmds}}\n\nARG GIT_USER_EMAIL\nARG GIT_USER_NAME\n\nRUN git config --global user.email \"$GIT_USER_EMAIL\" \u0026\u0026 \\\n git config --global user.name \"$GIT_USER_NAME\"\n\nLABEL sketch_context=\"{{.InitFilesHash}}\"\nCOPY . /app\n\nWORKDIR /app{{.SubDir}}\nRUN if [ -f go.mod ]; then go mod download; fi\n\nCMD [\"/bin/sketch\"]\n```\n\nIn particular:\n- Assume it is primarily a Go project. For a minimal env, prefer 1.24.2-alpine3.21 as a base image.\n- If any python is needed at all, switch to using a python alpine image as a the base and apk add go.\n Favor using uv, and use one of these base images, depending on the preferred python version:\n ghcr.io/astral-sh/uv:python3.13-alpine\n ghcr.io/astral-sh/uv:python3.12-alpine\n ghcr.io/astral-sh/uv:python3.11-alpine\n- When using pip to install packages, use: uv pip install --system.\n- Python env setup is challenging and often no required, so any RUN commands involving python tooling should be written to let docker build continue if there is a failure.\n- Include any tools particular to this repository that can be inferred from the given context.\n- Append || true to any apk add commands in case the package does not exist.\n- Do not expose any ports.\nHere is the content of several files from the repository that may be relevant:\n\n"
+ },
+ {
+ "type": "text",
+ "text": "Here is the contents .github/workflows/test.yml:\n\u003cfile\u003e\nname: Test\non: [push]\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v2\n - uses: actions/setup-node@v3\n with:\n node-version: '18'\n - name: Install and activate corepack\n run: |\n npm install -g corepack\n corepack enable\n - run: go test ./...\n\u003c/file\u003e\n\n"
+ },
+ {
+ "type": "text",
+ "text": "Here is the contents README.md:\n\u003cfile\u003e\n# Test Project\nA Go project for testing.\n\u003c/file\u003e\n\n"
+ },
+ {
+ "type": "text",
+ "text": "Now call the dockerfile tool.",
+ "cache_control": {
+ "type": "ephemeral"
+ }
+ }
+ ]
+ }
+ ],
+ "max_tokens": 8192,
+ "tools": [
+ {
+ "name": "dockerfile",
+ "description": "Helps define a Dockerfile that sets up a dev environment for this project.",
+ "input_schema": {
+ "type": "object",
+ "required": [
+ "from",
+ "extra_cmds"
+ ],
+ "properties": {
+ "from": {
+ "type": "string",
+ "description": "The alpine base image provided to the dockerfile FROM command"
+ },
+ "extra_cmds": {
+ "type": "string",
+ "description": "Extra commands to add to the dockerfile."
+ }
+ }
+ }
+ }
+ ]
+}HTTP/2.0 200 OK
+Anthropic-Organization-Id: 3c473a21-7208-450a-a9f8-80aebda45c1b
+Anthropic-Ratelimit-Input-Tokens-Limit: 200000
+Anthropic-Ratelimit-Input-Tokens-Remaining: 200000
+Anthropic-Ratelimit-Input-Tokens-Reset: 2025-04-05T00:17:31Z
+Anthropic-Ratelimit-Output-Tokens-Limit: 80000
+Anthropic-Ratelimit-Output-Tokens-Remaining: 80000
+Anthropic-Ratelimit-Output-Tokens-Reset: 2025-04-05T00:17:33Z
+Anthropic-Ratelimit-Requests-Limit: 4000
+Anthropic-Ratelimit-Requests-Remaining: 3999
+Anthropic-Ratelimit-Requests-Reset: 2025-04-05T00:17:31Z
+Anthropic-Ratelimit-Tokens-Limit: 280000
+Anthropic-Ratelimit-Tokens-Remaining: 280000
+Anthropic-Ratelimit-Tokens-Reset: 2025-04-05T00:17:31Z
+Cf-Cache-Status: DYNAMIC
+Cf-Ray: 92b4dce8bbcf943a-SJC
+Content-Type: application/json
+Date: Sat, 05 Apr 2025 00:17:33 GMT
+Request-Id: req_01HPsqm46UTtSfiiJ5GY6Fvg
+Server: cloudflare
+Via: 1.1 google
+X-Robots-Tag: none
+
+{"id":"msg_01HFWnuUBbvGmusGRZJbCKq2","type":"message","role":"assistant","model":"claude-3-7-sonnet-20250219","content":[{"type":"text","text":"Based on the provided files and requirements, I'll create a Dockerfile for this Go project.\n\nThe `.github/workflows/test.yml` indicates this is primarily a Go project with some Node.js dependencies. The workflow shows it uses Node.js 18 and corepack, so I'll include these in the extra commands."},{"type":"tool_use","id":"toolu_01AAUCno3BKmEJcZb4XWzQJ4","name":"dockerfile","input":{"from":"golang:1.24.2-alpine3.21","extra_cmds":"RUN npm install -g corepack && \\\n corepack enable || true"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":1139,"cache_read_input_tokens":0,"output_tokens":171}}
\ No newline at end of file
diff --git a/dockerimg/update_tests.sh b/dockerimg/update_tests.sh
new file mode 100755
index 0000000..5a42f7b
--- /dev/null
+++ b/dockerimg/update_tests.sh
@@ -0,0 +1,11 @@
+#!/bin/bash
+
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+CURRENT_DIR=$(pwd)
+
+if [ "$SCRIPT_DIR" != "$CURRENT_DIR" ]; then
+ echo "Error: This script must be run from its own directory: $SCRIPT_DIR" >&2
+ exit 1
+fi
+
+go test -httprecord ".*" -rewritewant