blob: 7178b94445e74fe52f2c203197b2e70c489aaec8 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package dockerimg
2
3import (
4 "bytes"
5 "context"
6 "crypto/sha256"
7 "encoding/hex"
8 "encoding/json"
9 "fmt"
10 "io/fs"
11 "maps"
12 "net/http"
13 "slices"
14 "strings"
15 "text/template"
16
17 "sketch.dev/ant"
18)
19
20func hashInitFiles(initFiles map[string]string) string {
21 h := sha256.New()
22 for _, path := range slices.Sorted(maps.Keys(initFiles)) {
23 fmt.Fprintf(h, "%s\n%s\n\n", path, initFiles[path])
24 }
David Crawshawbe10fa92025-04-18 01:16:00 -070025 fmt.Fprintf(h, "docker template\n%s\n", dockerfileBase)
Earl Lee2e463fb2025-04-17 11:22:22 -070026 return hex.EncodeToString(h.Sum(nil))
27}
28
David Crawshawbe10fa92025-04-18 01:16:00 -070029// TODO: add semgrep, prettier -- they require node/npm/etc which is more complicated than apk
30// If/when we do this, add them into the list of available tools in bash.go.
31const dockerfileBase = `FROM {{.From}}
32
Philip Zeyligercc3ba222025-04-23 14:52:21 -070033RUN apk add bash git make jq sqlite gcc musl-dev linux-headers npm nodejs go github-cli ripgrep fzf python3 curl vim
David Crawshawbe10fa92025-04-18 01:16:00 -070034
35ENV GOTOOLCHAIN=auto
36ENV GOPATH=/go
37ENV PATH="$GOPATH/bin:$PATH"
38
39RUN go install golang.org/x/tools/cmd/goimports@latest
40RUN go install golang.org/x/tools/gopls@latest
41RUN go install mvdan.cc/gofumpt@latest
42
43RUN mkdir -p /root/.cache/sketch/webui
44
45{{.ExtraCmds}}
46
47ARG GIT_USER_EMAIL
48ARG GIT_USER_NAME
49
50RUN git config --global user.email "$GIT_USER_EMAIL" && \
51 git config --global user.name "$GIT_USER_NAME"
52
53LABEL sketch_context="{{.InitFilesHash}}"
54COPY . /app
55
56WORKDIR /app{{.SubDir}}
57RUN if [ -f go.mod ]; then go mod download; fi
58
59CMD ["/bin/sketch"]`
60
Earl Lee2e463fb2025-04-17 11:22:22 -070061// createDockerfile creates a Dockerfile for a git repo.
62// It expects the relevant initFiles to have been provided.
63// If the sketch binary is being executed in a sub-directory of the repository,
64// the relative path is provided on subPathWorkingDir.
65func createDockerfile(ctx context.Context, httpc *http.Client, antURL, antAPIKey string, initFiles map[string]string, subPathWorkingDir string) (string, error) {
66 if subPathWorkingDir == "." {
67 subPathWorkingDir = ""
68 } else if subPathWorkingDir != "" && subPathWorkingDir[0] != '/' {
69 subPathWorkingDir = "/" + subPathWorkingDir
70 }
71 toolCalled := false
72 var dockerfileFROM, dockerfileExtraCmds string
73 runDockerfile := func(ctx context.Context, input json.RawMessage) (string, error) {
74 // TODO: unmarshal straight into a struct
75 var m map[string]any
76 if err := json.Unmarshal(input, &m); err != nil {
77 return "", fmt.Errorf(`input=%[1]v (%[1]T), wanted a map[string]any, got: %w`, input, err)
78 }
79 var ok bool
80 dockerfileFROM, ok = m["from"].(string)
81 if !ok {
82 return "", fmt.Errorf(`input["from"]=%[1]v (%[1]T), wanted a string`, m["path"])
83 }
84 dockerfileExtraCmds, ok = m["extra_cmds"].(string)
85 if !ok {
86 return "", fmt.Errorf(`input["extra_cmds"]=%[1]v (%[1]T), wanted a string`, m["path"])
87 }
88 toolCalled = true
89 return "OK", nil
90 }
91 convo := ant.NewConvo(ctx, antAPIKey)
92 if httpc != nil {
93 convo.HTTPC = httpc
94 }
95 if antURL != "" {
96 convo.URL = antURL
97 }
98 convo.Tools = []*ant.Tool{{
99 Name: "dockerfile",
100 Description: "Helps define a Dockerfile that sets up a dev environment for this project.",
101 Run: runDockerfile,
102 InputSchema: ant.MustSchema(`{
103 "type": "object",
104 "required": ["from", "extra_cmds"],
105 "properties": {
106 "from": {
107 "type": "string",
108 "description": "The alpine base image provided to the dockerfile FROM command"
109 },
110 "extra_cmds": {
111 "type": "string",
112 "description": "Extra commands to add to the dockerfile."
113 }
114 }
115}`),
116 }}
117
Earl Lee2e463fb2025-04-17 11:22:22 -0700118 // TODO: it's basically impossible to one-shot a python env. We need an agent loop for that.
119 // Right now the prompt contains a set of half-baked workarounds.
120
121 // If you want to edit the model prompt, run:
122 //
Philip Zeyligercc3ba222025-04-23 14:52:21 -0700123 // go test ./dockerimg -httprecord ".*" -rewritewant
Earl Lee2e463fb2025-04-17 11:22:22 -0700124 //
125 // Then look at the changes with:
126 //
Philip Zeyligercc3ba222025-04-23 14:52:21 -0700127 // git diff dockerimg/testdata/*.dockerfile
Earl Lee2e463fb2025-04-17 11:22:22 -0700128 //
129 // If the dockerfile changes are a strict improvement, commit all the changes.
130 msg := ant.Message{
131 Role: ant.MessageRoleUser,
132 Content: []ant.Content{{
133 Type: ant.ContentTypeText,
134 Text: `
135Call the dockerfile tool to create a Dockerfile.
136The parameters to dockerfile fill out the From and ExtraCmds
137template variables in the following Go template:
138
139` + "```\n" + dockerfileBase + "\n```" + `
140
141In particular:
142- Assume it is primarily a Go project. For a minimal env, prefer 1.24.2-alpine3.21 as a base image.
143- If any python is needed at all, switch to using a python alpine image as a the base and apk add go.
144 Favor using uv, and use one of these base images, depending on the preferred python version:
145 ghcr.io/astral-sh/uv:python3.13-alpine
146 ghcr.io/astral-sh/uv:python3.12-alpine
147 ghcr.io/astral-sh/uv:python3.11-alpine
148- When using pip to install packages, use: uv pip install --system.
149- 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.
150- Include any tools particular to this repository that can be inferred from the given context.
151- Append || true to any apk add commands in case the package does not exist.
152- Do not expose any ports.
153`,
154 }},
155 }
156 if len(initFiles) > 0 {
157 msg.Content[0].Text += "Here is the content of several files from the repository that may be relevant:\n\n"
158 }
159
160 for _, name := range slices.Sorted(maps.Keys(initFiles)) {
161 msg.Content = append(msg.Content, ant.Content{
162 Type: ant.ContentTypeText,
163 Text: fmt.Sprintf("Here is the contents %s:\n<file>\n%s\n</file>\n\n", name, initFiles[name]),
164 })
165 }
166 msg.Content = append(msg.Content, ant.Content{
167 Type: ant.ContentTypeText,
168 Text: "Now call the dockerfile tool.",
169 })
170 res, err := convo.SendMessage(msg)
171 if err != nil {
172 return "", err
173 }
174 if res.StopReason != ant.StopReasonToolUse {
175 return "", fmt.Errorf("expected stop reason %q, got %q", ant.StopReasonToolUse, res.StopReason)
176 }
177 if _, err := convo.ToolResultContents(context.TODO(), res); err != nil {
178 return "", err
179 }
180 if !toolCalled {
181 return "", fmt.Errorf("no dockerfile returned")
182 }
183
184 buf := new(bytes.Buffer)
185 err = template.Must(template.New("dockerfile").Parse(dockerfileBase)).Execute(buf, map[string]string{
186 "From": dockerfileFROM,
187 "ExtraCmds": dockerfileExtraCmds,
188 "InitFilesHash": hashInitFiles(initFiles),
189 "SubDir": subPathWorkingDir,
190 })
191 if err != nil {
192 return "", fmt.Errorf("dockerfile template failed: %w", err)
193 }
194
195 return buf.String(), nil
196}
197
198// 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
199
200func readInitFiles(fsys fs.FS) (map[string]string, error) {
201 result := make(map[string]string)
202
203 err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
204 if err != nil {
205 return err
206 }
207 if d.IsDir() && (d.Name() == ".git" || d.Name() == "node_modules") {
208 return fs.SkipDir
209 }
210 if !d.Type().IsRegular() {
211 return nil
212 }
213
214 // Case 1: Check for README files
215 // TODO: find README files between the .git root (where we start)
216 // and the dir that sketch was initialized. This needs more info
217 // plumbed to this function.
218 if strings.HasPrefix(strings.ToLower(path), "readme") {
219 content, err := fs.ReadFile(fsys, path)
220 if err != nil {
221 return err
222 }
223 result[path] = string(content)
224 return nil
225 }
226
227 // Case 2: Check for GitHub workflow files
228 if strings.HasPrefix(path, ".github/workflows/") {
229 content, err := fs.ReadFile(fsys, path)
230 if err != nil {
231 return err
232 }
233 result[path] = string(content)
234 return nil
235 }
236
237 return nil
238 })
239 if err != nil {
240 return nil, err
241 }
242 return result, nil
243}