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
+}