blob: c35aa484c05711ec745b66b6207f7cbbac2e26ac [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")
Earl Lee2e463fb2025-04-17 11:22:22 -070044 maxIterations := flag.Uint64("max-iterations", 0, "maximum number of iterations the agent should perform per turn, 0 to disable limit")
45 maxWallTime := flag.Duration("max-wall-time", 0, "maximum time the agent should run per turn, 0 to disable limit")
46 maxDollars := flag.Float64("max-dollars", 5.0, "maximum dollars the agent should spend per turn, 0 to disable limit")
47 one := flag.Bool("one", false, "run a single iteration and exit without termui")
Earl Lee2e463fb2025-04-17 11:22:22 -070048 verbose := flag.Bool("verbose", false, "enable verbose output")
49 version := flag.Bool("version", false, "print the version and exit")
Earl Lee2e463fb2025-04-17 11:22:22 -070050 workingDir := flag.String("C", "", "when set, change to this directory before running")
Sean McCulloughbaa2b592025-04-23 10:40:08 -070051 sshServerIdentity := flag.String("ssh_server_identity", "", "location of the file containing the private key that the container's ssh server will use to identify itself")
52 sshAuthorizedKeys := flag.String("ssh_authorized_keys", "", "location of the file containing the public keys that the container's ssh server will authorize")
Sean McCulloughae3480f2025-04-23 15:28:20 -070053 sshPort := flag.Int("ssh_port", 0, "the host port number that the container's ssh server will listen on, or a randomly chosen port if this value is 0")
Philip Zeyligerd1402952025-04-23 03:54:37 +000054
55 // Flags geared towards sketch developers or sketch internals:
56 gitUsername := flag.String("git-username", "", "(internal) username for git commits")
57 gitEmail := flag.String("git-email", "", "(internal) email for git commits")
58 sessionID := flag.String("session-id", newSessionID(), "(internal) unique session-id for a sketch process")
59 record := flag.Bool("httprecord", true, "(debugging) Record trace (if httprr is set)")
60 noCleanup := flag.Bool("nocleanup", false, "(debugging) do not clean up docker containers on exit")
61 containerLogDest := flag.String("save-container-logs", "", "(debugging) host path to save container logs to on exit")
Philip Zeyliger18532b22025-04-23 21:11:46 +000062 outsideHostname := flag.String("outside-hostname", "", "(internal) hostname on the outside system")
63 outsideOS := flag.String("outside-os", "", "(internal) OS on the outside system")
64 outsideWorkingDir := flag.String("outside-working-dir", "", "(internal) working dir on the outside system")
Philip Zeyligerd1402952025-04-23 03:54:37 +000065 sketchBinaryLinux := flag.String("sketch-binary-linux", "", "(development) path to a pre-built sketch binary for linux")
66
Earl Lee2e463fb2025-04-17 11:22:22 -070067 flag.Parse()
68
69 if *version {
70 bi, ok := debug.ReadBuildInfo()
71 if ok {
72 fmt.Printf("%s@%v\n", bi.Path, bi.Main.Version)
73 }
74 return nil
75 }
76
77 firstMessage := flag.Args()
78
79 // Add a global "session_id" to all logs using this context.
80 // A "session" is a single full run of the agent.
81 ctx := skribe.ContextWithAttr(context.Background(), slog.String("session_id", *sessionID))
82
83 var slogHandler slog.Handler
84 var err error
85 var logFile *os.File
Philip Zeyliger5e227dd2025-04-21 15:55:29 -070086 if !*one && !*verbose {
Earl Lee2e463fb2025-04-17 11:22:22 -070087 // Log to a file
88 logFile, err = os.CreateTemp("", "sketch-cli-log-*")
89 if err != nil {
90 return fmt.Errorf("cannot create log file: %v", err)
91 }
92 fmt.Printf("structured logs: %v\n", logFile.Name())
93 defer logFile.Close()
94 slogHandler = slog.NewJSONHandler(logFile, &slog.HandlerOptions{Level: slog.LevelDebug})
95 slogHandler = skribe.AttrsWrap(slogHandler)
96 } else {
97 // Log straight to stdout, no task_id
98 // TODO: verbosity controls?
99 slogHandler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})
100 // TODO: we skipped "AttrsWrap" here because it adds a bunch of line noise. do we want it anyway?
101 }
102 slog.SetDefault(slog.New(slogHandler))
103
104 if *workingDir != "" {
105 if err := os.Chdir(*workingDir); err != nil {
106 return fmt.Errorf("sketch: cannot change directory to %q: %v", *workingDir, err)
107 }
108 }
109
110 if *gitUsername == "" {
111 *gitUsername = defaultGitUsername()
112 }
113 if *gitEmail == "" {
114 *gitEmail = defaultGitEmail()
115 }
116
117 inDocker := false
118 if _, err := os.Stat("/.dockerenv"); err == nil {
119 inDocker = true
120 }
121
122 if !inDocker {
Philip Zeyliger8a89e1c2025-04-21 15:27:13 -0700123 msgs, err := hostReqsCheck(*unsafe)
Earl Lee2e463fb2025-04-17 11:22:22 -0700124 if *verbose {
125 fmt.Println("Host requirement checks:")
126 for _, m := range msgs {
127 fmt.Println(m)
128 }
129 }
130 if err != nil {
131 return err
132 }
133 }
134
135 if *one && len(firstMessage) == 0 {
136 return fmt.Errorf("-one flag requires a message to send to the agent")
137 }
138
139 var pubKey, antURL, apiKey string
140 if *skabandAddr == "" {
141 apiKey = os.Getenv("ANTHROPIC_API_KEY")
142 if apiKey == "" {
143 return fmt.Errorf("ANTHROPIC_API_KEY environment variable is not set")
144 }
145 } else {
146 if inDocker {
147 apiKey = os.Getenv("ANTHROPIC_API_KEY")
148 pubKey = os.Getenv("SKETCH_PUB_KEY")
149 antURL, err = skabandclient.LocalhostToDockerInternal(os.Getenv("ANT_URL"))
150 if err != nil {
151 return err
152 }
153 } else {
154 privKey, err := skabandclient.LoadOrCreatePrivateKey(skabandclient.DefaultKeyPath())
155 if err != nil {
156 return err
157 }
158 pubKey, antURL, apiKey, err = skabandclient.Login(os.Stdout, privKey, *skabandAddr, *sessionID)
159 if err != nil {
160 return err
161 }
162 }
163 }
164
165 if !*unsafe {
166 cwd, err := os.Getwd()
167 if err != nil {
168 return fmt.Errorf("sketch: cannot determine current working directory: %v", err)
169 }
170 // TODO: this is a bit of a mess.
171 // The "stdout" and "stderr" used here are "just" for verbose logs from LaunchContainer.
172 // LaunchContainer has to attach the termui, and does that directly to os.Stdout/os.Stderr
173 // regardless of what is attached here.
174 // This is probably wrong. Instead of having a big "if verbose" switch here, the verbosity
175 // switches should be inside LaunchContainer and os.Stdout/os.Stderr should be passed in
176 // here (with the parameters being kept for future testing).
177 var stdout, stderr io.Writer
178 var outbuf, errbuf *bytes.Buffer
179 if *verbose {
180 stdout, stderr = os.Stdout, os.Stderr
181 } else {
182 outbuf, errbuf = &bytes.Buffer{}, &bytes.Buffer{}
183 stdout, stderr = outbuf, errbuf
184 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700185
186 var authorizedKeys, serverIdentity []byte
187 if *sshAuthorizedKeys != "" {
188 authorizedKeys, err = os.ReadFile(*sshAuthorizedKeys)
189 if err != nil {
190 return fmt.Errorf("reading ssh_authorized_keys from %s: %w", *sshAuthorizedKeys, err)
191 }
192 }
193 if *sshServerIdentity != "" {
194 serverIdentity, err = os.ReadFile(*sshServerIdentity)
195 if err != nil {
196 return fmt.Errorf("reading ssh_id_rsa from %s: %w", *sshServerIdentity, err)
197 }
198 }
199
Earl Lee2e463fb2025-04-17 11:22:22 -0700200 fmt.Printf("launching container...\n")
201 config := dockerimg.ContainerConfig{
202 SessionID: *sessionID,
203 LocalAddr: *addr,
204 SkabandAddr: *skabandAddr,
205 AntURL: antURL,
206 AntAPIKey: apiKey,
207 Path: cwd,
208 GitUsername: *gitUsername,
209 GitEmail: *gitEmail,
210 OpenBrowser: *openBrowser,
211 NoCleanup: *noCleanup,
212 ContainerLogDest: *containerLogDest,
213 SketchBinaryLinux: *sketchBinaryLinux,
214 SketchPubKey: pubKey,
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700215 SSHServerIdentity: serverIdentity,
216 SSHAuthorizedKeys: authorizedKeys,
217 SSHPort: *sshPort,
Earl Lee2e463fb2025-04-17 11:22:22 -0700218 ForceRebuild: false,
Philip Zeyliger18532b22025-04-23 21:11:46 +0000219 OutsideHostname: getHostname(),
220 OutsideOS: runtime.GOOS,
221 OutsideWorkingDir: cwd,
Earl Lee2e463fb2025-04-17 11:22:22 -0700222 }
223 if err := dockerimg.LaunchContainer(ctx, stdout, stderr, config); err != nil {
224 if *verbose {
225 fmt.Fprintf(os.Stderr, "dockerimg.LaunchContainer failed: %v\ndockerimg.LaunchContainer stderr:\n%s\ndockerimg.LaunchContainer stdout:\n%s\n", err, errbuf.String(), outbuf.String())
226 }
227 return err
228 }
229 return nil
230 }
231
232 var client *http.Client
233 if *httprrFile != "" {
234 var err error
235 var rr *httprr.RecordReplay
236 if *record {
237 rr, err = httprr.OpenForRecording(*httprrFile, http.DefaultTransport)
238 } else {
239 rr, err = httprr.Open(*httprrFile, http.DefaultTransport)
240 }
241 if err != nil {
242 return fmt.Errorf("httprr: %v", err)
243 }
244 // Scrub API keys from requests for security
245 rr.ScrubReq(func(req *http.Request) error {
246 req.Header.Del("x-api-key")
247 req.Header.Del("anthropic-api-key")
248 return nil
249 })
250 client = rr.Client()
251 }
252 wd, err := os.Getwd()
253 if err != nil {
254 return err
255 }
256
257 agentConfig := loop.AgentConfig{
Philip Zeyliger18532b22025-04-23 21:11:46 +0000258 Context: ctx,
259 AntURL: antURL,
260 APIKey: apiKey,
261 HTTPC: client,
262 Budget: ant.Budget{MaxResponses: *maxIterations, MaxWallTime: *maxWallTime, MaxDollars: *maxDollars},
263 GitUsername: *gitUsername,
264 GitEmail: *gitEmail,
265 SessionID: *sessionID,
266 ClientGOOS: runtime.GOOS,
267 ClientGOARCH: runtime.GOARCH,
268 UseAnthropicEdit: os.Getenv("SKETCH_ANTHROPIC_EDIT") == "1",
269 OutsideHostname: *outsideHostname,
270 OutsideOS: *outsideOS,
271 OutsideWorkingDir: *outsideWorkingDir,
Earl Lee2e463fb2025-04-17 11:22:22 -0700272 }
273 agent := loop.NewAgent(agentConfig)
274
275 srv, err := server.New(agent, logFile)
276 if err != nil {
277 return err
278 }
279
280 if !inDocker {
281 ini := loop.AgentInit{
282 WorkingDir: wd,
283 }
284 if err = agent.Init(ini); err != nil {
285 return fmt.Errorf("failed to initialize agent: %v", err)
286 }
287 }
288
289 // Start the agent
290 go agent.Loop(ctx)
291
292 // Start the local HTTP server.
293 ln, err := net.Listen("tcp", *addr)
294 if err != nil {
295 return fmt.Errorf("cannot create debug server listener: %v", err)
296 }
297 go (&http.Server{Handler: srv}).Serve(ln)
298 var ps1URL string
299 if *skabandAddr != "" {
300 ps1URL = fmt.Sprintf("%s/s/%s", *skabandAddr, *sessionID)
301 } else if !inDocker {
302 // Do not tell users about the port inside the container, let the
303 // process running on the host report this.
304 ps1URL = fmt.Sprintf("http://%s", ln.Addr())
305 }
306
307 if len(firstMessage) > 0 {
308 agent.UserMessage(ctx, strings.Join(firstMessage, " "))
309 }
310
311 if inDocker {
312 <-agent.Ready()
313 if ps1URL == "" {
314 ps1URL = agent.URL()
315 }
316 }
317
Philip Zeyliger6ed6adb2025-04-23 19:56:38 -0700318 // Open the web UI URL in the system browser if requested
Earl Lee2e463fb2025-04-17 11:22:22 -0700319 if *openBrowser {
320 dockerimg.OpenBrowser(ctx, ps1URL)
321 }
322
323 // Create the termui instance
324 s := termui.New(agent, ps1URL)
325 defer func() {
326 r := recover()
327 if err := s.RestoreOldState(); err != nil {
328 fmt.Fprintf(os.Stderr, "couldn't restore old terminal state: %s\n", err)
329 }
330 if r != nil {
331 panic(r)
332 }
333 }()
334
335 // Start skaband connection loop if needed
336 if *skabandAddr != "" {
337 connectFn := func(connected bool) {
David Crawshaw7b436622025-04-24 17:49:01 +0000338 if *verbose {
339 if connected {
340 s.AppendSystemMessage("skaband connected")
341 } else {
342 s.AppendSystemMessage("skaband disconnected")
343 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700344 }
345 }
346 go skabandclient.DialAndServeLoop(ctx, *skabandAddr, *sessionID, pubKey, srv, connectFn)
347 }
348
349 if *one {
350 for {
351 m := agent.WaitForMessage(ctx)
352 if m.Content != "" {
353 fmt.Printf("💬 %s %s: %s\n", m.Timestamp.Format("15:04:05"), m.Type, m.Content)
354 }
355 if m.EndOfTurn && m.ParentConversationID == nil {
356 fmt.Printf("Total cost: $%0.2f\n", agent.TotalUsage().TotalCostUSD)
357 return nil
358 }
359 }
360 }
361
362 if err := s.Run(ctx); err != nil {
363 return err
364 }
365
366 return nil
367}
368
369// newSessionID generates a new 10-byte random Session ID.
370func newSessionID() string {
371 u1, u2 := rand.Uint64(), rand.Uint64N(1<<16)
372 s := crock32.Encode(u1) + crock32.Encode(uint64(u2))
373 if len(s) < 16 {
374 s += strings.Repeat("0", 16-len(s))
375 }
376 return s[0:4] + "-" + s[4:8] + "-" + s[8:12] + "-" + s[12:16]
377}
378
Philip Zeyligerd1402952025-04-23 03:54:37 +0000379func getHostname() string {
380 hostname, err := os.Hostname()
381 if err != nil {
382 return "unknown"
383 }
384 return hostname
385}
386
Earl Lee2e463fb2025-04-17 11:22:22 -0700387func defaultGitUsername() string {
388 out, err := exec.Command("git", "config", "user.name").CombinedOutput()
389 if err != nil {
390 return "Sketch🕴️" // TODO: what should this be?
391 }
392 return strings.TrimSpace(string(out))
393}
394
395func defaultGitEmail() string {
396 out, err := exec.Command("git", "config", "user.email").CombinedOutput()
397 if err != nil {
398 return "skallywag@sketch.dev" // TODO: what should this be?
399 }
400 return strings.TrimSpace(string(out))
401}