blob: 6ef8049c0350bf2a507386035c09abcb9257d012 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package main
2
3import (
4 "bytes"
5 "context"
6 "flag"
7 "fmt"
8 "io"
9 "log/slog"
10 "math/rand/v2"
11 "net"
12 "net/http"
13 "os"
14 "os/exec"
15 "runtime"
16 "runtime/debug"
17 "strings"
18
19 "github.com/richardlehane/crock32"
20 "sketch.dev/ant"
21 "sketch.dev/dockerimg"
22 "sketch.dev/httprr"
23 "sketch.dev/loop"
24 "sketch.dev/loop/server"
25 "sketch.dev/skabandclient"
26 "sketch.dev/skribe"
27 "sketch.dev/termui"
28)
29
30func main() {
31 err := run()
32 if err != nil {
33 fmt.Fprintf(os.Stderr, "%v: %v\n", os.Args[0], err)
34 os.Exit(1)
35 }
36}
37
38func run() error {
39 addr := flag.String("addr", "localhost:0", "local debug HTTP server address")
40 skabandAddr := flag.String("skaband-addr", "https://sketch.dev", "URL of the skaband server")
41 unsafe := flag.Bool("unsafe", false, "run directly without a docker container")
42 openBrowser := flag.Bool("open", false, "open sketch URL in system browser")
43 httprrFile := flag.String("httprr", "", "if set, record HTTP interactions to file")
44 record := flag.Bool("httprecord", true, "Record trace (if httprr is set)")
45 maxIterations := flag.Uint64("max-iterations", 0, "maximum number of iterations the agent should perform per turn, 0 to disable limit")
46 maxWallTime := flag.Duration("max-wall-time", 0, "maximum time the agent should run per turn, 0 to disable limit")
47 maxDollars := flag.Float64("max-dollars", 5.0, "maximum dollars the agent should spend per turn, 0 to disable limit")
48 one := flag.Bool("one", false, "run a single iteration and exit without termui")
49 sessionID := flag.String("session-id", newSessionID(), "unique session-id for a sketch process")
50 gitUsername := flag.String("git-username", "", "username for git commits")
51 gitEmail := flag.String("git-email", "", "email for git commits")
52 verbose := flag.Bool("verbose", false, "enable verbose output")
53 version := flag.Bool("version", false, "print the version and exit")
54 noCleanup := flag.Bool("nocleanup", false, "do not clean up docker containers on exit")
55 containerLogDest := flag.String("save-container-logs", "", "host path to save container logs to on exit")
56 sketchBinaryLinux := flag.String("sketch-binary-linux", "", "path to a pre-built sketch binary for linux")
57 workingDir := flag.String("C", "", "when set, change to this directory before running")
58 flag.Parse()
59
60 if *version {
61 bi, ok := debug.ReadBuildInfo()
62 if ok {
63 fmt.Printf("%s@%v\n", bi.Path, bi.Main.Version)
64 }
65 return nil
66 }
67
68 firstMessage := flag.Args()
69
70 // Add a global "session_id" to all logs using this context.
71 // A "session" is a single full run of the agent.
72 ctx := skribe.ContextWithAttr(context.Background(), slog.String("session_id", *sessionID))
73
74 var slogHandler slog.Handler
75 var err error
76 var logFile *os.File
77 if !*one {
78 // Log to a file
79 logFile, err = os.CreateTemp("", "sketch-cli-log-*")
80 if err != nil {
81 return fmt.Errorf("cannot create log file: %v", err)
82 }
83 fmt.Printf("structured logs: %v\n", logFile.Name())
84 defer logFile.Close()
85 slogHandler = slog.NewJSONHandler(logFile, &slog.HandlerOptions{Level: slog.LevelDebug})
86 slogHandler = skribe.AttrsWrap(slogHandler)
87 } else {
88 // Log straight to stdout, no task_id
89 // TODO: verbosity controls?
90 slogHandler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})
91 // TODO: we skipped "AttrsWrap" here because it adds a bunch of line noise. do we want it anyway?
92 }
93 slog.SetDefault(slog.New(slogHandler))
94
95 if *workingDir != "" {
96 if err := os.Chdir(*workingDir); err != nil {
97 return fmt.Errorf("sketch: cannot change directory to %q: %v", *workingDir, err)
98 }
99 }
100
101 if *gitUsername == "" {
102 *gitUsername = defaultGitUsername()
103 }
104 if *gitEmail == "" {
105 *gitEmail = defaultGitEmail()
106 }
107
108 inDocker := false
109 if _, err := os.Stat("/.dockerenv"); err == nil {
110 inDocker = true
111 }
112
113 if !inDocker {
114 msgs, err := hostReqsCheck()
115 if *verbose {
116 fmt.Println("Host requirement checks:")
117 for _, m := range msgs {
118 fmt.Println(m)
119 }
120 }
121 if err != nil {
122 return err
123 }
124 }
125
126 if *one && len(firstMessage) == 0 {
127 return fmt.Errorf("-one flag requires a message to send to the agent")
128 }
129
130 var pubKey, antURL, apiKey string
131 if *skabandAddr == "" {
132 apiKey = os.Getenv("ANTHROPIC_API_KEY")
133 if apiKey == "" {
134 return fmt.Errorf("ANTHROPIC_API_KEY environment variable is not set")
135 }
136 } else {
137 if inDocker {
138 apiKey = os.Getenv("ANTHROPIC_API_KEY")
139 pubKey = os.Getenv("SKETCH_PUB_KEY")
140 antURL, err = skabandclient.LocalhostToDockerInternal(os.Getenv("ANT_URL"))
141 if err != nil {
142 return err
143 }
144 } else {
145 privKey, err := skabandclient.LoadOrCreatePrivateKey(skabandclient.DefaultKeyPath())
146 if err != nil {
147 return err
148 }
149 pubKey, antURL, apiKey, err = skabandclient.Login(os.Stdout, privKey, *skabandAddr, *sessionID)
150 if err != nil {
151 return err
152 }
153 }
154 }
155
156 if !*unsafe {
157 cwd, err := os.Getwd()
158 if err != nil {
159 return fmt.Errorf("sketch: cannot determine current working directory: %v", err)
160 }
161 // TODO: this is a bit of a mess.
162 // The "stdout" and "stderr" used here are "just" for verbose logs from LaunchContainer.
163 // LaunchContainer has to attach the termui, and does that directly to os.Stdout/os.Stderr
164 // regardless of what is attached here.
165 // This is probably wrong. Instead of having a big "if verbose" switch here, the verbosity
166 // switches should be inside LaunchContainer and os.Stdout/os.Stderr should be passed in
167 // here (with the parameters being kept for future testing).
168 var stdout, stderr io.Writer
169 var outbuf, errbuf *bytes.Buffer
170 if *verbose {
171 stdout, stderr = os.Stdout, os.Stderr
172 } else {
173 outbuf, errbuf = &bytes.Buffer{}, &bytes.Buffer{}
174 stdout, stderr = outbuf, errbuf
175 }
176 fmt.Printf("launching container...\n")
177 config := dockerimg.ContainerConfig{
178 SessionID: *sessionID,
179 LocalAddr: *addr,
180 SkabandAddr: *skabandAddr,
181 AntURL: antURL,
182 AntAPIKey: apiKey,
183 Path: cwd,
184 GitUsername: *gitUsername,
185 GitEmail: *gitEmail,
186 OpenBrowser: *openBrowser,
187 NoCleanup: *noCleanup,
188 ContainerLogDest: *containerLogDest,
189 SketchBinaryLinux: *sketchBinaryLinux,
190 SketchPubKey: pubKey,
191 ForceRebuild: false,
192 }
193 if err := dockerimg.LaunchContainer(ctx, stdout, stderr, config); err != nil {
194 if *verbose {
195 fmt.Fprintf(os.Stderr, "dockerimg.LaunchContainer failed: %v\ndockerimg.LaunchContainer stderr:\n%s\ndockerimg.LaunchContainer stdout:\n%s\n", err, errbuf.String(), outbuf.String())
196 }
197 return err
198 }
199 return nil
200 }
201
202 var client *http.Client
203 if *httprrFile != "" {
204 var err error
205 var rr *httprr.RecordReplay
206 if *record {
207 rr, err = httprr.OpenForRecording(*httprrFile, http.DefaultTransport)
208 } else {
209 rr, err = httprr.Open(*httprrFile, http.DefaultTransport)
210 }
211 if err != nil {
212 return fmt.Errorf("httprr: %v", err)
213 }
214 // Scrub API keys from requests for security
215 rr.ScrubReq(func(req *http.Request) error {
216 req.Header.Del("x-api-key")
217 req.Header.Del("anthropic-api-key")
218 return nil
219 })
220 client = rr.Client()
221 }
222 wd, err := os.Getwd()
223 if err != nil {
224 return err
225 }
226
227 agentConfig := loop.AgentConfig{
228 Context: ctx,
229 AntURL: antURL,
230 APIKey: apiKey,
231 HTTPC: client,
232 Budget: ant.Budget{MaxResponses: *maxIterations, MaxWallTime: *maxWallTime, MaxDollars: *maxDollars},
233 GitUsername: *gitUsername,
234 GitEmail: *gitEmail,
235 SessionID: *sessionID,
236 ClientGOOS: runtime.GOOS,
237 ClientGOARCH: runtime.GOARCH,
238 UseAnthropicEdit: os.Getenv("SKETCH_ANTHROPIC_EDIT") == "1",
239 }
240 agent := loop.NewAgent(agentConfig)
241
242 srv, err := server.New(agent, logFile)
243 if err != nil {
244 return err
245 }
246
247 if !inDocker {
248 ini := loop.AgentInit{
249 WorkingDir: wd,
250 }
251 if err = agent.Init(ini); err != nil {
252 return fmt.Errorf("failed to initialize agent: %v", err)
253 }
254 }
255
256 // Start the agent
257 go agent.Loop(ctx)
258
259 // Start the local HTTP server.
260 ln, err := net.Listen("tcp", *addr)
261 if err != nil {
262 return fmt.Errorf("cannot create debug server listener: %v", err)
263 }
264 go (&http.Server{Handler: srv}).Serve(ln)
265 var ps1URL string
266 if *skabandAddr != "" {
267 ps1URL = fmt.Sprintf("%s/s/%s", *skabandAddr, *sessionID)
268 } else if !inDocker {
269 // Do not tell users about the port inside the container, let the
270 // process running on the host report this.
271 ps1URL = fmt.Sprintf("http://%s", ln.Addr())
272 }
273
274 if len(firstMessage) > 0 {
275 agent.UserMessage(ctx, strings.Join(firstMessage, " "))
276 }
277
278 if inDocker {
279 <-agent.Ready()
280 if ps1URL == "" {
281 ps1URL = agent.URL()
282 }
283 }
284
285 // Open the debug URL in the system browser if requested
286 if *openBrowser {
287 dockerimg.OpenBrowser(ctx, ps1URL)
288 }
289
290 // Create the termui instance
291 s := termui.New(agent, ps1URL)
292 defer func() {
293 r := recover()
294 if err := s.RestoreOldState(); err != nil {
295 fmt.Fprintf(os.Stderr, "couldn't restore old terminal state: %s\n", err)
296 }
297 if r != nil {
298 panic(r)
299 }
300 }()
301
302 // Start skaband connection loop if needed
303 if *skabandAddr != "" {
304 connectFn := func(connected bool) {
305 if connected {
306 s.AppendSystemMessage("skaband connected")
307 } else {
308 s.AppendSystemMessage("skaband disconnected")
309 }
310 }
311 go skabandclient.DialAndServeLoop(ctx, *skabandAddr, *sessionID, pubKey, srv, connectFn)
312 }
313
314 if *one {
315 for {
316 m := agent.WaitForMessage(ctx)
317 if m.Content != "" {
318 fmt.Printf("💬 %s %s: %s\n", m.Timestamp.Format("15:04:05"), m.Type, m.Content)
319 }
320 if m.EndOfTurn && m.ParentConversationID == nil {
321 fmt.Printf("Total cost: $%0.2f\n", agent.TotalUsage().TotalCostUSD)
322 return nil
323 }
324 }
325 }
326
327 if err := s.Run(ctx); err != nil {
328 return err
329 }
330
331 return nil
332}
333
334// newSessionID generates a new 10-byte random Session ID.
335func newSessionID() string {
336 u1, u2 := rand.Uint64(), rand.Uint64N(1<<16)
337 s := crock32.Encode(u1) + crock32.Encode(uint64(u2))
338 if len(s) < 16 {
339 s += strings.Repeat("0", 16-len(s))
340 }
341 return s[0:4] + "-" + s[4:8] + "-" + s[8:12] + "-" + s[12:16]
342}
343
344func defaultGitUsername() string {
345 out, err := exec.Command("git", "config", "user.name").CombinedOutput()
346 if err != nil {
347 return "Sketch🕴️" // TODO: what should this be?
348 }
349 return strings.TrimSpace(string(out))
350}
351
352func defaultGitEmail() string {
353 out, err := exec.Command("git", "config", "user.email").CombinedOutput()
354 if err != nil {
355 return "skallywag@sketch.dev" // TODO: what should this be?
356 }
357 return strings.TrimSpace(string(out))
358}