blob: cc07d10f83962bd5826e6b5eef9f6ddab5996414 [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"
Josh Bleecher Snyder78707d62025-04-30 21:06:49 +000021 "sketch.dev/browser"
Earl Lee2e463fb2025-04-17 11:22:22 -070022 "sketch.dev/dockerimg"
23 "sketch.dev/httprr"
24 "sketch.dev/loop"
25 "sketch.dev/loop/server"
26 "sketch.dev/skabandclient"
27 "sketch.dev/skribe"
28 "sketch.dev/termui"
29)
30
31func main() {
32 err := run()
33 if err != nil {
34 fmt.Fprintf(os.Stderr, "%v: %v\n", os.Args[0], err)
35 os.Exit(1)
36 }
37}
38
39func run() error {
40 addr := flag.String("addr", "localhost:0", "local debug HTTP server address")
41 skabandAddr := flag.String("skaband-addr", "https://sketch.dev", "URL of the skaband server")
42 unsafe := flag.Bool("unsafe", false, "run directly without a docker container")
Josh Bleecher Snyder3cae7d92025-04-30 09:54:29 -070043 openBrowser := flag.Bool("open", true, "open sketch URL in system browser")
Earl Lee2e463fb2025-04-17 11:22:22 -070044 httprrFile := flag.String("httprr", "", "if set, record HTTP interactions to file")
Earl Lee2e463fb2025-04-17 11:22:22 -070045 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")
Pokey Rule0dcebe12025-04-28 14:51:04 +010048 oneShot := flag.Bool("one-shot", false, "exit after the first turn without termui")
49 prompt := flag.String("prompt", "", "prompt to send to sketch")
Earl Lee2e463fb2025-04-17 11:22:22 -070050 verbose := flag.Bool("verbose", false, "enable verbose output")
51 version := flag.Bool("version", false, "print the version and exit")
Earl Lee2e463fb2025-04-17 11:22:22 -070052 workingDir := flag.String("C", "", "when set, change to this directory before running")
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 Zeyliger176a5102025-04-24 20:32:37 -070054 forceRebuild := flag.Bool("force-rebuild-container", false, "rebuild Docker container")
Philip Zeyliger1b47aa22025-04-28 19:25:38 +000055 initialCommit := flag.String("initial-commit", "HEAD", "the git commit reference to use as starting point (incompatible with -unsafe)")
Philip Zeyligerd1402952025-04-23 03:54:37 +000056
57 // Flags geared towards sketch developers or sketch internals:
58 gitUsername := flag.String("git-username", "", "(internal) username for git commits")
59 gitEmail := flag.String("git-email", "", "(internal) email for git commits")
60 sessionID := flag.String("session-id", newSessionID(), "(internal) unique session-id for a sketch process")
61 record := flag.Bool("httprecord", true, "(debugging) Record trace (if httprr is set)")
62 noCleanup := flag.Bool("nocleanup", false, "(debugging) do not clean up docker containers on exit")
63 containerLogDest := flag.String("save-container-logs", "", "(debugging) host path to save container logs to on exit")
Philip Zeyliger18532b22025-04-23 21:11:46 +000064 outsideHostname := flag.String("outside-hostname", "", "(internal) hostname on the outside system")
65 outsideOS := flag.String("outside-os", "", "(internal) OS on the outside system")
66 outsideWorkingDir := flag.String("outside-working-dir", "", "(internal) working dir on the outside system")
Philip Zeyligerd1402952025-04-23 03:54:37 +000067 sketchBinaryLinux := flag.String("sketch-binary-linux", "", "(development) path to a pre-built sketch binary for linux")
68
Earl Lee2e463fb2025-04-17 11:22:22 -070069 flag.Parse()
70
Philip Zeyliger1b47aa22025-04-28 19:25:38 +000071 if *unsafe && *initialCommit != "HEAD" {
72 return fmt.Errorf("cannot use -initial-commit with -unsafe, they are incompatible")
73 }
74
Earl Lee2e463fb2025-04-17 11:22:22 -070075 if *version {
76 bi, ok := debug.ReadBuildInfo()
77 if ok {
78 fmt.Printf("%s@%v\n", bi.Path, bi.Main.Version)
79 }
80 return nil
81 }
82
Earl Lee2e463fb2025-04-17 11:22:22 -070083 // Add a global "session_id" to all logs using this context.
84 // A "session" is a single full run of the agent.
85 ctx := skribe.ContextWithAttr(context.Background(), slog.String("session_id", *sessionID))
86
87 var slogHandler slog.Handler
88 var err error
89 var logFile *os.File
Pokey Rule0dcebe12025-04-28 14:51:04 +010090 if !*oneShot && !*verbose {
Earl Lee2e463fb2025-04-17 11:22:22 -070091 // Log to a file
92 logFile, err = os.CreateTemp("", "sketch-cli-log-*")
93 if err != nil {
94 return fmt.Errorf("cannot create log file: %v", err)
95 }
David Crawshawfaa39be2025-04-25 08:06:38 -070096 if *unsafe {
97 fmt.Printf("structured logs: %v\n", logFile.Name())
98 }
Earl Lee2e463fb2025-04-17 11:22:22 -070099 defer logFile.Close()
100 slogHandler = slog.NewJSONHandler(logFile, &slog.HandlerOptions{Level: slog.LevelDebug})
101 slogHandler = skribe.AttrsWrap(slogHandler)
102 } else {
103 // Log straight to stdout, no task_id
104 // TODO: verbosity controls?
105 slogHandler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})
106 // TODO: we skipped "AttrsWrap" here because it adds a bunch of line noise. do we want it anyway?
107 }
108 slog.SetDefault(slog.New(slogHandler))
109
110 if *workingDir != "" {
111 if err := os.Chdir(*workingDir); err != nil {
112 return fmt.Errorf("sketch: cannot change directory to %q: %v", *workingDir, err)
113 }
114 }
115
116 if *gitUsername == "" {
117 *gitUsername = defaultGitUsername()
118 }
119 if *gitEmail == "" {
120 *gitEmail = defaultGitEmail()
121 }
122
123 inDocker := false
124 if _, err := os.Stat("/.dockerenv"); err == nil {
125 inDocker = true
126 }
Philip Zeyliger5fdd0242025-04-25 19:31:58 -0700127 inInsideSketch := inDocker && *outsideHostname != ""
Earl Lee2e463fb2025-04-17 11:22:22 -0700128
129 if !inDocker {
Philip Zeyliger8a89e1c2025-04-21 15:27:13 -0700130 msgs, err := hostReqsCheck(*unsafe)
Earl Lee2e463fb2025-04-17 11:22:22 -0700131 if *verbose {
132 fmt.Println("Host requirement checks:")
133 for _, m := range msgs {
134 fmt.Println(m)
135 }
136 }
137 if err != nil {
138 return err
139 }
140 }
141
Earl Lee2e463fb2025-04-17 11:22:22 -0700142 var pubKey, antURL, apiKey string
143 if *skabandAddr == "" {
144 apiKey = os.Getenv("ANTHROPIC_API_KEY")
145 if apiKey == "" {
146 return fmt.Errorf("ANTHROPIC_API_KEY environment variable is not set")
147 }
148 } else {
149 if inDocker {
150 apiKey = os.Getenv("ANTHROPIC_API_KEY")
151 pubKey = os.Getenv("SKETCH_PUB_KEY")
152 antURL, err = skabandclient.LocalhostToDockerInternal(os.Getenv("ANT_URL"))
153 if err != nil {
154 return err
155 }
156 } else {
157 privKey, err := skabandclient.LoadOrCreatePrivateKey(skabandclient.DefaultKeyPath())
158 if err != nil {
159 return err
160 }
161 pubKey, antURL, apiKey, err = skabandclient.Login(os.Stdout, privKey, *skabandAddr, *sessionID)
162 if err != nil {
163 return err
164 }
165 }
166 }
167
168 if !*unsafe {
169 cwd, err := os.Getwd()
170 if err != nil {
171 return fmt.Errorf("sketch: cannot determine current working directory: %v", err)
172 }
173 // TODO: this is a bit of a mess.
174 // The "stdout" and "stderr" used here are "just" for verbose logs from LaunchContainer.
175 // LaunchContainer has to attach the termui, and does that directly to os.Stdout/os.Stderr
176 // regardless of what is attached here.
177 // This is probably wrong. Instead of having a big "if verbose" switch here, the verbosity
178 // switches should be inside LaunchContainer and os.Stdout/os.Stderr should be passed in
179 // here (with the parameters being kept for future testing).
180 var stdout, stderr io.Writer
181 var outbuf, errbuf *bytes.Buffer
182 if *verbose {
183 stdout, stderr = os.Stdout, os.Stderr
184 } else {
185 outbuf, errbuf = &bytes.Buffer{}, &bytes.Buffer{}
186 stdout, stderr = outbuf, errbuf
187 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700188
Earl Lee2e463fb2025-04-17 11:22:22 -0700189 config := dockerimg.ContainerConfig{
190 SessionID: *sessionID,
191 LocalAddr: *addr,
192 SkabandAddr: *skabandAddr,
193 AntURL: antURL,
194 AntAPIKey: apiKey,
195 Path: cwd,
196 GitUsername: *gitUsername,
197 GitEmail: *gitEmail,
198 OpenBrowser: *openBrowser,
199 NoCleanup: *noCleanup,
200 ContainerLogDest: *containerLogDest,
201 SketchBinaryLinux: *sketchBinaryLinux,
202 SketchPubKey: pubKey,
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700203 SSHPort: *sshPort,
Philip Zeyliger176a5102025-04-24 20:32:37 -0700204 ForceRebuild: *forceRebuild,
Philip Zeyliger18532b22025-04-23 21:11:46 +0000205 OutsideHostname: getHostname(),
206 OutsideOS: runtime.GOOS,
207 OutsideWorkingDir: cwd,
Philip Zeyligerb74c4f62025-04-25 19:18:49 -0700208 OneShot: *oneShot,
Pokey Rule0dcebe12025-04-28 14:51:04 +0100209 Prompt: *prompt,
Philip Zeyliger1b47aa22025-04-28 19:25:38 +0000210 InitialCommit: *initialCommit,
Earl Lee2e463fb2025-04-17 11:22:22 -0700211 }
212 if err := dockerimg.LaunchContainer(ctx, stdout, stderr, config); err != nil {
213 if *verbose {
214 fmt.Fprintf(os.Stderr, "dockerimg.LaunchContainer failed: %v\ndockerimg.LaunchContainer stderr:\n%s\ndockerimg.LaunchContainer stdout:\n%s\n", err, errbuf.String(), outbuf.String())
215 }
216 return err
217 }
218 return nil
219 }
220
221 var client *http.Client
222 if *httprrFile != "" {
223 var err error
224 var rr *httprr.RecordReplay
225 if *record {
226 rr, err = httprr.OpenForRecording(*httprrFile, http.DefaultTransport)
227 } else {
228 rr, err = httprr.Open(*httprrFile, http.DefaultTransport)
229 }
230 if err != nil {
231 return fmt.Errorf("httprr: %v", err)
232 }
233 // Scrub API keys from requests for security
234 rr.ScrubReq(func(req *http.Request) error {
235 req.Header.Del("x-api-key")
236 req.Header.Del("anthropic-api-key")
237 return nil
238 })
239 client = rr.Client()
240 }
241 wd, err := os.Getwd()
242 if err != nil {
243 return err
244 }
245
246 agentConfig := loop.AgentConfig{
Philip Zeyliger18532b22025-04-23 21:11:46 +0000247 Context: ctx,
248 AntURL: antURL,
249 APIKey: apiKey,
250 HTTPC: client,
251 Budget: ant.Budget{MaxResponses: *maxIterations, MaxWallTime: *maxWallTime, MaxDollars: *maxDollars},
252 GitUsername: *gitUsername,
253 GitEmail: *gitEmail,
254 SessionID: *sessionID,
255 ClientGOOS: runtime.GOOS,
256 ClientGOARCH: runtime.GOARCH,
257 UseAnthropicEdit: os.Getenv("SKETCH_ANTHROPIC_EDIT") == "1",
258 OutsideHostname: *outsideHostname,
259 OutsideOS: *outsideOS,
260 OutsideWorkingDir: *outsideWorkingDir,
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700261 InDocker: inDocker,
Earl Lee2e463fb2025-04-17 11:22:22 -0700262 }
263 agent := loop.NewAgent(agentConfig)
264
265 srv, err := server.New(agent, logFile)
266 if err != nil {
267 return err
268 }
269
Philip Zeyliger5fdd0242025-04-25 19:31:58 -0700270 if !inInsideSketch {
Earl Lee2e463fb2025-04-17 11:22:22 -0700271 ini := loop.AgentInit{
272 WorkingDir: wd,
273 }
274 if err = agent.Init(ini); err != nil {
275 return fmt.Errorf("failed to initialize agent: %v", err)
276 }
277 }
278
279 // Start the agent
280 go agent.Loop(ctx)
281
282 // Start the local HTTP server.
283 ln, err := net.Listen("tcp", *addr)
284 if err != nil {
285 return fmt.Errorf("cannot create debug server listener: %v", err)
286 }
287 go (&http.Server{Handler: srv}).Serve(ln)
288 var ps1URL string
289 if *skabandAddr != "" {
290 ps1URL = fmt.Sprintf("%s/s/%s", *skabandAddr, *sessionID)
291 } else if !inDocker {
292 // Do not tell users about the port inside the container, let the
293 // process running on the host report this.
294 ps1URL = fmt.Sprintf("http://%s", ln.Addr())
295 }
296
Philip Zeyliger5fdd0242025-04-25 19:31:58 -0700297 if inInsideSketch {
Earl Lee2e463fb2025-04-17 11:22:22 -0700298 <-agent.Ready()
299 if ps1URL == "" {
300 ps1URL = agent.URL()
301 }
302 }
303
Pokey Rule0dcebe12025-04-28 14:51:04 +0100304 // Use prompt if provided
305 if *prompt != "" {
306 agent.UserMessage(ctx, *prompt)
Philip Zeyligerb74c4f62025-04-25 19:18:49 -0700307 }
308
Philip Zeyliger6ed6adb2025-04-23 19:56:38 -0700309 // Open the web UI URL in the system browser if requested
Earl Lee2e463fb2025-04-17 11:22:22 -0700310 if *openBrowser {
Josh Bleecher Snydere54b00a2025-04-30 16:48:02 -0700311 browser.Open(ps1URL)
Earl Lee2e463fb2025-04-17 11:22:22 -0700312 }
313
314 // Create the termui instance
315 s := termui.New(agent, ps1URL)
Earl Lee2e463fb2025-04-17 11:22:22 -0700316
317 // Start skaband connection loop if needed
318 if *skabandAddr != "" {
319 connectFn := func(connected bool) {
David Crawshaw7b436622025-04-24 17:49:01 +0000320 if *verbose {
321 if connected {
322 s.AppendSystemMessage("skaband connected")
323 } else {
324 s.AppendSystemMessage("skaband disconnected")
325 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700326 }
327 }
328 go skabandclient.DialAndServeLoop(ctx, *skabandAddr, *sessionID, pubKey, srv, connectFn)
329 }
330
Pokey Rule0dcebe12025-04-28 14:51:04 +0100331 if *oneShot {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700332 it := agent.NewIterator(ctx, 0)
Earl Lee2e463fb2025-04-17 11:22:22 -0700333 for {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700334 m := it.Next()
335 if m == nil {
336 return nil
337 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700338 if m.Content != "" {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700339 fmt.Printf("[%d] 💬 %s %s: %s\n", m.Idx, m.Timestamp.Format("15:04:05"), m.Type, m.Content)
Earl Lee2e463fb2025-04-17 11:22:22 -0700340 }
341 if m.EndOfTurn && m.ParentConversationID == nil {
342 fmt.Printf("Total cost: $%0.2f\n", agent.TotalUsage().TotalCostUSD)
343 return nil
344 }
345 }
346 }
347
Philip Zeyligerb74c4f62025-04-25 19:18:49 -0700348 defer func() {
349 r := recover()
350 if err := s.RestoreOldState(); err != nil {
351 fmt.Fprintf(os.Stderr, "couldn't restore old terminal state: %s\n", err)
352 }
353 if r != nil {
354 panic(r)
355 }
356 }()
Earl Lee2e463fb2025-04-17 11:22:22 -0700357 if err := s.Run(ctx); err != nil {
358 return err
359 }
360
361 return nil
362}
363
364// newSessionID generates a new 10-byte random Session ID.
365func newSessionID() string {
366 u1, u2 := rand.Uint64(), rand.Uint64N(1<<16)
367 s := crock32.Encode(u1) + crock32.Encode(uint64(u2))
368 if len(s) < 16 {
369 s += strings.Repeat("0", 16-len(s))
370 }
371 return s[0:4] + "-" + s[4:8] + "-" + s[8:12] + "-" + s[12:16]
372}
373
Philip Zeyligerd1402952025-04-23 03:54:37 +0000374func getHostname() string {
375 hostname, err := os.Hostname()
376 if err != nil {
377 return "unknown"
378 }
379 return hostname
380}
381
Earl Lee2e463fb2025-04-17 11:22:22 -0700382func defaultGitUsername() string {
383 out, err := exec.Command("git", "config", "user.name").CombinedOutput()
384 if err != nil {
385 return "Sketch🕴️" // TODO: what should this be?
386 }
387 return strings.TrimSpace(string(out))
388}
389
390func defaultGitEmail() string {
391 out, err := exec.Command("git", "config", "user.email").CombinedOutput()
392 if err != nil {
393 return "skallywag@sketch.dev" // TODO: what should this be?
394 }
395 return strings.TrimSpace(string(out))
396}