blob: 775da54208afa0abca57c9084efc0538c4535edf [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package main
2
3import (
Josh Bleecher Snydere3c2f222025-05-15 20:54:52 +00004 "cmp"
Earl Lee2e463fb2025-04-17 11:22:22 -07005 "context"
6 "flag"
7 "fmt"
Earl Lee2e463fb2025-04-17 11:22:22 -07008 "log/slog"
Earl Lee2e463fb2025-04-17 11:22:22 -07009 "net"
10 "net/http"
11 "os"
12 "os/exec"
13 "runtime"
14 "runtime/debug"
15 "strings"
Sean McCulloughc8e4ab02025-05-02 16:11:37 +000016 "time"
Earl Lee2e463fb2025-04-17 11:22:22 -070017
Josh Bleecher Snyderb4782142025-05-05 19:16:54 +000018 "sketch.dev/experiment"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070019 "sketch.dev/llm"
David Crawshaw5a234062025-05-04 17:52:08 +000020 "sketch.dev/llm/gem"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070021 "sketch.dev/llm/oai"
22
Josh Bleecher Snyder78707d62025-04-30 21:06:49 +000023 "sketch.dev/browser"
Earl Lee2e463fb2025-04-17 11:22:22 -070024 "sketch.dev/dockerimg"
25 "sketch.dev/httprr"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070026 "sketch.dev/llm/ant"
27 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070028 "sketch.dev/loop"
29 "sketch.dev/loop/server"
30 "sketch.dev/skabandclient"
31 "sketch.dev/skribe"
32 "sketch.dev/termui"
Philip Zeyligerc5b8ed42025-05-05 20:28:34 +000033
34 "golang.org/x/term"
Earl Lee2e463fb2025-04-17 11:22:22 -070035)
36
37func main() {
38 err := run()
39 if err != nil {
40 fmt.Fprintf(os.Stderr, "%v: %v\n", os.Args[0], err)
41 os.Exit(1)
42 }
43}
44
Sean McCulloughc8e4ab02025-05-02 16:11:37 +000045// run is the main entry point that parses flags and dispatches to the appropriate
46// execution path based on whether we're running in a container or not.
Earl Lee2e463fb2025-04-17 11:22:22 -070047func run() error {
Sean McCulloughc8e4ab02025-05-02 16:11:37 +000048 flagArgs := parseCLIFlags()
Philip Zeyligerd1402952025-04-23 03:54:37 +000049
Sean McCulloughc8e4ab02025-05-02 16:11:37 +000050 if flagArgs.version {
Earl Lee2e463fb2025-04-17 11:22:22 -070051 bi, ok := debug.ReadBuildInfo()
52 if ok {
53 fmt.Printf("%s@%v\n", bi.Path, bi.Main.Version)
54 }
55 return nil
56 }
57
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070058 if flagArgs.listModels {
59 fmt.Println("Available models:")
60 fmt.Println("- claude (default, uses Anthropic service)")
David Crawshaw5a234062025-05-04 17:52:08 +000061 fmt.Println("- gemini (uses Google Gemini 2.5 Pro service)")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070062 for _, name := range oai.ListModels() {
63 note := ""
64 if name != "gpt4.1" {
65 note = " (not recommended)"
66 }
67 fmt.Printf("- %s%s\n", name, note)
68 }
69 return nil
70 }
71
David Crawshaw5a234062025-05-04 17:52:08 +000072 // Claude and Gemini are supported in container mode
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070073 // TODO: finish support--thread through API keys, add server support
David Crawshaw5a234062025-05-04 17:52:08 +000074 isContainerSupported := flagArgs.modelName == "claude" || flagArgs.modelName == "" || flagArgs.modelName == "gemini"
75 if !isContainerSupported && (!flagArgs.unsafe || flagArgs.skabandAddr != "") {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070076 return fmt.Errorf("only -model=claude is supported in safe mode right now, use -unsafe -skaband-addr=''")
77 }
78
Josh Bleecher Snyderb4782142025-05-05 19:16:54 +000079 if err := flagArgs.experimentFlag.Process(); err != nil {
80 fmt.Fprintf(os.Stderr, "error parsing experimental flags: %v\n", err)
81 os.Exit(1)
82 }
83 if experiment.Enabled("list") {
84 experiment.Fprint(os.Stdout)
85 os.Exit(0)
86 }
87
Earl Lee2e463fb2025-04-17 11:22:22 -070088 // Add a global "session_id" to all logs using this context.
89 // A "session" is a single full run of the agent.
Sean McCulloughc8e4ab02025-05-02 16:11:37 +000090 ctx := skribe.ContextWithAttr(context.Background(), slog.String("session_id", flagArgs.sessionID))
Earl Lee2e463fb2025-04-17 11:22:22 -070091
Sean McCulloughc8e4ab02025-05-02 16:11:37 +000092 // Configure logging
Philip Zeyliger613c0f52025-05-15 16:36:22 -070093 slogHandler, logFile, err := setupLogging(flagArgs.termUI, flagArgs.verbose, flagArgs.unsafe)
Sean McCulloughc8e4ab02025-05-02 16:11:37 +000094 if err != nil {
95 return err
96 }
97 if logFile != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -070098 defer logFile.Close()
Earl Lee2e463fb2025-04-17 11:22:22 -070099 }
100 slog.SetDefault(slog.New(slogHandler))
101
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000102 // Change to working directory if specified
103 if flagArgs.workingDir != "" {
104 if err := os.Chdir(flagArgs.workingDir); err != nil {
105 return fmt.Errorf("sketch: cannot change directory to %q: %v", flagArgs.workingDir, err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700106 }
107 }
108
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000109 // Set default git username and email if not provided
110 if flagArgs.gitUsername == "" {
111 flagArgs.gitUsername = defaultGitUsername()
Earl Lee2e463fb2025-04-17 11:22:22 -0700112 }
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000113 if flagArgs.gitEmail == "" {
114 flagArgs.gitEmail = defaultGitEmail()
Earl Lee2e463fb2025-04-17 11:22:22 -0700115 }
116
Philip Zeyliger6234a8d2025-05-02 14:31:20 -0700117 // Detect if we're inside the sketch container
118 inInsideSketch := flagArgs.outsideHostname != ""
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000119
120 // Validate initial commit and unsafe flag combination
121 if flagArgs.unsafe && flagArgs.initialCommit != "HEAD" {
122 return fmt.Errorf("cannot use -initial-commit with -unsafe, they are incompatible")
Earl Lee2e463fb2025-04-17 11:22:22 -0700123 }
124
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000125 // Dispatch to the appropriate execution path
Philip Zeyliger6234a8d2025-05-02 14:31:20 -0700126 if inInsideSketch {
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000127 // We're running inside the Docker container
Philip Zeyliger6234a8d2025-05-02 14:31:20 -0700128 return runInContainerMode(ctx, flagArgs, logFile)
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000129 } else if flagArgs.unsafe {
130 // We're running directly on the host in unsafe mode
131 return runInUnsafeMode(ctx, flagArgs, logFile)
132 } else {
133 // We're running on the host and need to launch a container
134 return runInHostMode(ctx, flagArgs)
135 }
136}
137
138// CLIFlags holds all command-line arguments
Josh Bleecher Snyderac761c92025-05-16 18:58:45 +0000139// StringSliceFlag is a custom flag type that allows for repeated flag values.
140// It collects all values into a slice.
141type StringSliceFlag []string
142
143// String returns the string representation of the flag value.
144func (f *StringSliceFlag) String() string {
145 return strings.Join(*f, ",")
146}
147
148// Set adds a value to the flag.
149func (f *StringSliceFlag) Set(value string) error {
150 *f = append(*f, value)
151 return nil
152}
153
154// Get returns the flag values.
155func (f *StringSliceFlag) Get() any {
156 return []string(*f)
157}
158
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000159type CLIFlags struct {
160 addr string
161 skabandAddr string
162 unsafe bool
163 openBrowser bool
164 httprrFile string
165 maxIterations uint64
166 maxWallTime time.Duration
167 maxDollars float64
168 oneShot bool
169 prompt string
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700170 modelName string
Josh Bleecher Snydere3c2f222025-05-15 20:54:52 +0000171 llmAPIKey string
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700172 listModels bool
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000173 verbose bool
174 version bool
175 workingDir string
176 sshPort int
177 forceRebuild bool
178 initialCommit string
179 gitUsername string
180 gitEmail string
Josh Bleecher Snyderb4782142025-05-05 19:16:54 +0000181 experimentFlag experiment.Flag
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000182 sessionID string
183 record bool
184 noCleanup bool
185 containerLogDest string
186 outsideHostname string
187 outsideOS string
188 outsideWorkingDir string
189 sketchBinaryLinux string
Philip Zeyliger1dc21372025-05-05 19:54:44 +0000190 dockerArgs string
Josh Bleecher Snyderac761c92025-05-16 18:58:45 +0000191 mounts StringSliceFlag
Philip Zeyligerc5b8ed42025-05-05 20:28:34 +0000192 termUI bool
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700193 gitRemoteURL string
194 commit string
195 outsideHTTP string
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000196}
197
198// parseCLIFlags parses all command-line flags and returns a CLIFlags struct
199func parseCLIFlags() CLIFlags {
200 var flags CLIFlags
201
202 flag.StringVar(&flags.addr, "addr", "localhost:0", "local debug HTTP server address")
203 flag.StringVar(&flags.skabandAddr, "skaband-addr", "https://sketch.dev", "URL of the skaband server")
204 flag.BoolVar(&flags.unsafe, "unsafe", false, "run directly without a docker container")
Marc-Antoine Ruelda796542025-05-14 08:20:11 -0400205 flag.BoolVar(&flags.openBrowser, "open", true, "open sketch URL in system browser; on by default except if -one-shot is used or a ssh connection is detected")
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000206 flag.StringVar(&flags.httprrFile, "httprr", "", "if set, record HTTP interactions to file")
207 flag.Uint64Var(&flags.maxIterations, "max-iterations", 0, "maximum number of iterations the agent should perform per turn, 0 to disable limit")
208 flag.DurationVar(&flags.maxWallTime, "max-wall-time", 0, "maximum time the agent should run per turn, 0 to disable limit")
Josh Bleecher Snyder2044abb2025-05-14 17:31:20 +0000209 flag.Float64Var(&flags.maxDollars, "max-dollars", 10.0, "maximum dollars the agent should spend per turn, 0 to disable limit")
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000210 flag.BoolVar(&flags.oneShot, "one-shot", false, "exit after the first turn without termui")
211 flag.StringVar(&flags.prompt, "prompt", "", "prompt to send to sketch")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700212 flag.StringVar(&flags.modelName, "model", "claude", "model to use (e.g. claude, gpt4.1)")
Josh Bleecher Snydere3c2f222025-05-15 20:54:52 +0000213 flag.StringVar(&flags.llmAPIKey, "llm-api-key", "", "API key for the LLM provider; if not set, will be read from an env var")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700214 flag.BoolVar(&flags.listModels, "list-models", false, "list all available models and exit")
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000215 flag.BoolVar(&flags.verbose, "verbose", false, "enable verbose output")
216 flag.BoolVar(&flags.version, "version", false, "print the version and exit")
217 flag.StringVar(&flags.workingDir, "C", "", "when set, change to this directory before running")
218 flag.IntVar(&flags.sshPort, "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")
219 flag.BoolVar(&flags.forceRebuild, "force-rebuild-container", false, "rebuild Docker container")
220 flag.StringVar(&flags.initialCommit, "initial-commit", "HEAD", "the git commit reference to use as starting point (incompatible with -unsafe)")
Philip Zeyliger1dc21372025-05-05 19:54:44 +0000221 flag.StringVar(&flags.dockerArgs, "docker-args", "", "additional arguments to pass to the docker create command (e.g., --memory=2g --cpus=2)")
Josh Bleecher Snyderac761c92025-05-16 18:58:45 +0000222 flag.Var(&flags.mounts, "mount", "volume to mount in the container in format /path/on/host:/path/in/container (can be repeated)")
Philip Zeyligerc5b8ed42025-05-05 20:28:34 +0000223 flag.BoolVar(&flags.termUI, "termui", true, "enable terminal UI")
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000224
225 // Flags geared towards sketch developers or sketch internals:
226 flag.StringVar(&flags.gitUsername, "git-username", "", "(internal) username for git commits")
227 flag.StringVar(&flags.gitEmail, "git-email", "", "(internal) email for git commits")
David Crawshaw0ead54d2025-05-16 13:58:36 -0700228 flag.StringVar(&flags.sessionID, "session-id", skabandclient.NewSessionID(), "(internal) unique session-id for a sketch process")
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000229 flag.BoolVar(&flags.record, "httprecord", true, "(debugging) Record trace (if httprr is set)")
230 flag.BoolVar(&flags.noCleanup, "nocleanup", false, "(debugging) do not clean up docker containers on exit")
231 flag.StringVar(&flags.containerLogDest, "save-container-logs", "", "(debugging) host path to save container logs to on exit")
232 flag.StringVar(&flags.outsideHostname, "outside-hostname", "", "(internal) hostname on the outside system")
233 flag.StringVar(&flags.outsideOS, "outside-os", "", "(internal) OS on the outside system")
234 flag.StringVar(&flags.outsideWorkingDir, "outside-working-dir", "", "(internal) working dir on the outside system")
235 flag.StringVar(&flags.sketchBinaryLinux, "sketch-binary-linux", "", "(development) path to a pre-built sketch binary for linux")
Josh Bleecher Snyderb4782142025-05-05 19:16:54 +0000236 flag.Var(&flags.experimentFlag, "x", "enable experimental features (comma-separated list or repeat flag; use 'list' to show all)")
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000237
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700238 flag.StringVar(&flags.gitRemoteURL, "git-remote-url", "", "(internal) git remote for outside sketch")
239 flag.StringVar(&flags.commit, "commit", "", "(internal) the git commit reference to check out from git remote url")
240 flag.StringVar(&flags.outsideHTTP, "outside-http", "", "(internal) host for outside sketch")
241
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000242 flag.Parse()
Josh Bleecher Snydera23587b2025-05-07 12:42:53 +0000243
Marc-Antoine Ruelda796542025-05-14 08:20:11 -0400244 // -open's default value is not a simple true/false; it depends on other flags and conditions.
Josh Bleecher Snydera23587b2025-05-07 12:42:53 +0000245 // Distinguish between -open default value vs explicitly set.
246 openExplicit := false
247 flag.Visit(func(f *flag.Flag) {
248 if f.Name == "open" {
249 openExplicit = true
250 }
251 })
252 if !openExplicit {
Marc-Antoine Ruelda796542025-05-14 08:20:11 -0400253 // Not explicitly set.
254 // Calculate the right default value: true except with one-shot mode or if we're running in a ssh session.
David Crawshaw5eccd682025-05-16 07:09:30 -0700255 flags.openBrowser = !flags.oneShot && os.Getenv("SSH_CONNECTION") == ""
Josh Bleecher Snydera23587b2025-05-07 12:42:53 +0000256 }
257
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000258 return flags
259}
260
261// runInHostMode handles execution on the host machine, which typically involves
262// checking host requirements and launching a Docker container.
263func runInHostMode(ctx context.Context, flags CLIFlags) error {
264 // Check host requirements
265 msgs, err := hostReqsCheck(flags.unsafe)
266 if flags.verbose {
267 fmt.Println("Host requirement checks:")
268 for _, m := range msgs {
269 fmt.Println(m)
270 }
271 }
272 if err != nil {
273 return err
274 }
275
276 // Get credentials and connect to skaband if needed
Josh Bleecher Snydere3c2f222025-05-15 20:54:52 +0000277 var pubKey, modelURL, apiKey string
278 if flags.skabandAddr != "" {
279 privKey, err := skabandclient.LoadOrCreatePrivateKey(skabandclient.DefaultKeyPath())
280 if err != nil {
281 return err
282 }
283 pubKey, modelURL, apiKey, err = skabandclient.Login(os.Stdout, privKey, flags.skabandAddr, flags.sessionID, flags.modelName)
284 if err != nil {
285 return err
286 }
Pokey Rule12989b02025-05-23 11:27:47 +0100287 } else {
288 // When not using skaband, get API key from environment or flag
289 envName := "ANTHROPIC_API_KEY"
290 if flags.modelName == "gemini" {
291 envName = gem.GeminiAPIKeyEnv
292 }
293 apiKey = cmp.Or(os.Getenv(envName), flags.llmAPIKey)
294 if apiKey == "" {
295 return fmt.Errorf("%s environment variable is not set, -llm-api-key flag not provided", envName)
296 }
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000297 }
298
299 // Get current working directory
300 cwd, err := os.Getwd()
301 if err != nil {
302 return fmt.Errorf("sketch: cannot determine current working directory: %v", err)
303 }
304
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000305 // Configure and launch the container
306 config := dockerimg.ContainerConfig{
307 SessionID: flags.sessionID,
308 LocalAddr: flags.addr,
309 SkabandAddr: flags.skabandAddr,
David Crawshaw5a7b3692025-05-05 16:49:15 -0700310 Model: flags.modelName,
311 ModelURL: modelURL,
312 ModelAPIKey: apiKey,
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000313 Path: cwd,
314 GitUsername: flags.gitUsername,
315 GitEmail: flags.gitEmail,
316 OpenBrowser: flags.openBrowser,
317 NoCleanup: flags.noCleanup,
318 ContainerLogDest: flags.containerLogDest,
319 SketchBinaryLinux: flags.sketchBinaryLinux,
320 SketchPubKey: pubKey,
321 SSHPort: flags.sshPort,
322 ForceRebuild: flags.forceRebuild,
323 OutsideHostname: getHostname(),
324 OutsideOS: runtime.GOOS,
325 OutsideWorkingDir: cwd,
326 OneShot: flags.oneShot,
327 Prompt: flags.prompt,
328 InitialCommit: flags.initialCommit,
David Crawshawb5f6a002025-05-05 08:27:16 -0700329 Verbose: flags.verbose,
Philip Zeyliger1dc21372025-05-05 19:54:44 +0000330 DockerArgs: flags.dockerArgs,
Josh Bleecher Snyderac761c92025-05-16 18:58:45 +0000331 Mounts: flags.mounts,
Josh Bleecher Snyderb1cca6f2025-05-06 01:52:55 +0000332 ExperimentFlag: flags.experimentFlag.String(),
Philip Zeyliger613c0f52025-05-15 16:36:22 -0700333 TermUI: flags.termUI,
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000334 }
335
David Crawshawb5f6a002025-05-05 08:27:16 -0700336 if err := dockerimg.LaunchContainer(ctx, config); err != nil {
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000337 if flags.verbose {
David Crawshawb5f6a002025-05-05 08:27:16 -0700338 fmt.Fprintf(os.Stderr, "dockerimg launch container failed: %v\n", err)
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000339 }
340 return err
341 }
342
343 return nil
344}
345
346// runInContainerMode handles execution inside the Docker container.
347// The inInsideSketch parameter indicates whether we're inside the sketch container
348// with access to outside environment variables.
Philip Zeyliger6234a8d2025-05-02 14:31:20 -0700349func runInContainerMode(ctx context.Context, flags CLIFlags, logFile *os.File) error {
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000350 // Get credentials from environment
Josh Bleecher Snydere3c2f222025-05-15 20:54:52 +0000351 apiKey := cmp.Or(os.Getenv("SKETCH_MODEL_API_KEY"), flags.llmAPIKey)
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000352 pubKey := os.Getenv("SKETCH_PUB_KEY")
David Crawshaw3659d872025-05-05 17:52:23 -0700353 modelURL, err := skabandclient.LocalhostToDockerInternal(os.Getenv("SKETCH_MODEL_URL"))
354 if err != nil && os.Getenv("SKETCH_MODEL_URL") != "" {
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000355 return err
356 }
357
David Crawshaw3659d872025-05-05 17:52:23 -0700358 return setupAndRunAgent(ctx, flags, modelURL, apiKey, pubKey, true, logFile)
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000359}
360
361// runInUnsafeMode handles execution on the host machine without Docker.
362// This mode is used when the -unsafe flag is provided.
363func runInUnsafeMode(ctx context.Context, flags CLIFlags, logFile *os.File) error {
364 // Check if we need to get the API key from environment
365 var apiKey, antURL, pubKey string
366
367 if flags.skabandAddr == "" {
David Crawshaw961cc9e2025-05-05 14:33:33 -0700368 envName := "ANTHROPIC_API_KEY"
369 if flags.modelName == "gemini" {
370 envName = gem.GeminiAPIKeyEnv
371 }
Josh Bleecher Snydere3c2f222025-05-15 20:54:52 +0000372 apiKey = cmp.Or(os.Getenv(envName), flags.llmAPIKey)
Earl Lee2e463fb2025-04-17 11:22:22 -0700373 if apiKey == "" {
Josh Bleecher Snydere3c2f222025-05-15 20:54:52 +0000374 return fmt.Errorf("%s environment variable is not set, -llm-api-key flag not provided", envName)
Earl Lee2e463fb2025-04-17 11:22:22 -0700375 }
376 } else {
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000377 // Connect to skaband
378 privKey, err := skabandclient.LoadOrCreatePrivateKey(skabandclient.DefaultKeyPath())
Earl Lee2e463fb2025-04-17 11:22:22 -0700379 if err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700380 return err
381 }
David Crawshaw961cc9e2025-05-05 14:33:33 -0700382 pubKey, antURL, apiKey, err = skabandclient.Login(os.Stdout, privKey, flags.skabandAddr, flags.sessionID, flags.modelName)
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000383 if err != nil {
384 return err
385 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700386 }
387
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000388 return setupAndRunAgent(ctx, flags, antURL, apiKey, pubKey, false, logFile)
389}
390
391// setupAndRunAgent handles the common logic for setting up and running the agent
392// in both container and unsafe modes.
David Crawshaw3659d872025-05-05 17:52:23 -0700393func setupAndRunAgent(ctx context.Context, flags CLIFlags, modelURL, apiKey, pubKey string, inInsideSketch bool, logFile *os.File) error {
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000394 // Configure HTTP client with optional recording
Earl Lee2e463fb2025-04-17 11:22:22 -0700395 var client *http.Client
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000396 if flags.httprrFile != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -0700397 var err error
398 var rr *httprr.RecordReplay
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000399 if flags.record {
400 rr, err = httprr.OpenForRecording(flags.httprrFile, http.DefaultTransport)
Earl Lee2e463fb2025-04-17 11:22:22 -0700401 } else {
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000402 rr, err = httprr.Open(flags.httprrFile, http.DefaultTransport)
Earl Lee2e463fb2025-04-17 11:22:22 -0700403 }
404 if err != nil {
405 return fmt.Errorf("httprr: %v", err)
406 }
407 // Scrub API keys from requests for security
408 rr.ScrubReq(func(req *http.Request) error {
409 req.Header.Del("x-api-key")
410 req.Header.Del("anthropic-api-key")
411 return nil
412 })
413 client = rr.Client()
414 }
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000415
Earl Lee2e463fb2025-04-17 11:22:22 -0700416 wd, err := os.Getwd()
417 if err != nil {
418 return err
419 }
420
David Crawshaw3659d872025-05-05 17:52:23 -0700421 llmService, err := selectLLMService(client, flags.modelName, modelURL, apiKey)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700422 if err != nil {
423 return fmt.Errorf("failed to initialize LLM service: %w", err)
424 }
425 budget := conversation.Budget{
426 MaxResponses: flags.maxIterations,
427 MaxWallTime: flags.maxWallTime,
428 MaxDollars: flags.maxDollars,
429 }
430
Earl Lee2e463fb2025-04-17 11:22:22 -0700431 agentConfig := loop.AgentConfig{
Philip Zeyliger18532b22025-04-23 21:11:46 +0000432 Context: ctx,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700433 Service: llmService,
434 Budget: budget,
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000435 GitUsername: flags.gitUsername,
436 GitEmail: flags.gitEmail,
437 SessionID: flags.sessionID,
Philip Zeyliger18532b22025-04-23 21:11:46 +0000438 ClientGOOS: runtime.GOOS,
439 ClientGOARCH: runtime.GOARCH,
440 UseAnthropicEdit: os.Getenv("SKETCH_ANTHROPIC_EDIT") == "1",
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000441 OutsideHostname: flags.outsideHostname,
442 OutsideOS: flags.outsideOS,
443 OutsideWorkingDir: flags.outsideWorkingDir,
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700444 WorkingDir: wd,
Philip Zeyliger49edc922025-05-14 09:45:45 -0700445 // Ultimately this is a subtle flag because it's trying to distinguish
446 // between unsafe-on-host and inside sketch, and should probably be renamed/simplified.
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700447 InDocker: flags.outsideHostname != "",
448 OneShot: flags.oneShot,
449 GitRemoteAddr: flags.gitRemoteURL,
450 OutsideHTTP: flags.outsideHTTP,
Philip Zeyliger716bfee2025-05-21 18:32:31 -0700451 Commit: flags.commit,
Earl Lee2e463fb2025-04-17 11:22:22 -0700452 }
453 agent := loop.NewAgent(agentConfig)
454
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000455 // Create the server
Earl Lee2e463fb2025-04-17 11:22:22 -0700456 srv, err := server.New(agent, logFile)
457 if err != nil {
458 return err
459 }
460
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000461 // Initialize the agent (only needed when not inside sketch with outside hostname)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700462 // In the innie case, outtie sends a POST /init
Philip Zeyliger5fdd0242025-04-25 19:31:58 -0700463 if !inInsideSketch {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700464 if err = agent.Init(loop.AgentInit{}); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700465 return fmt.Errorf("failed to initialize agent: %v", err)
466 }
467 }
468
469 // Start the agent
470 go agent.Loop(ctx)
471
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000472 // Start the local HTTP server
473 ln, err := net.Listen("tcp", flags.addr)
Earl Lee2e463fb2025-04-17 11:22:22 -0700474 if err != nil {
475 return fmt.Errorf("cannot create debug server listener: %v", err)
476 }
477 go (&http.Server{Handler: srv}).Serve(ln)
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000478
479 // Determine the URL to display
Earl Lee2e463fb2025-04-17 11:22:22 -0700480 var ps1URL string
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000481 if flags.skabandAddr != "" {
482 ps1URL = fmt.Sprintf("%s/s/%s", flags.skabandAddr, flags.sessionID)
483 } else if !agentConfig.InDocker {
Earl Lee2e463fb2025-04-17 11:22:22 -0700484 // Do not tell users about the port inside the container, let the
485 // process running on the host report this.
486 ps1URL = fmt.Sprintf("http://%s", ln.Addr())
487 }
488
Philip Zeyliger5fdd0242025-04-25 19:31:58 -0700489 if inInsideSketch {
Earl Lee2e463fb2025-04-17 11:22:22 -0700490 <-agent.Ready()
491 if ps1URL == "" {
492 ps1URL = agent.URL()
493 }
494 }
495
Pokey Rule0dcebe12025-04-28 14:51:04 +0100496 // Use prompt if provided
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000497 if flags.prompt != "" {
498 agent.UserMessage(ctx, flags.prompt)
Philip Zeyligerb74c4f62025-04-25 19:18:49 -0700499 }
500
Philip Zeyliger6ed6adb2025-04-23 19:56:38 -0700501 // Open the web UI URL in the system browser if requested
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000502 if flags.openBrowser {
Josh Bleecher Snydere54b00a2025-04-30 16:48:02 -0700503 browser.Open(ps1URL)
Earl Lee2e463fb2025-04-17 11:22:22 -0700504 }
505
Philip Zeyligerc5b8ed42025-05-05 20:28:34 +0000506 // Check if terminal UI should be enabled
507 // Disable termui if the flag is explicitly set to false or if we detect no PTY is available
508 if !term.IsTerminal(int(os.Stdin.Fd())) {
509 flags.termUI = false
510 }
511
512 // Create a variable for terminal UI
513 var s *termui.TermUI
514
515 // Create the termui instance only if needed
516 if flags.termUI {
517 s = termui.New(agent, ps1URL)
518 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700519
520 // Start skaband connection loop if needed
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000521 if flags.skabandAddr != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -0700522 connectFn := func(connected bool) {
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000523 if flags.verbose {
David Crawshaw7b436622025-04-24 17:49:01 +0000524 if connected {
Philip Zeyligerc5b8ed42025-05-05 20:28:34 +0000525 if s != nil {
526 s.AppendSystemMessage("skaband connected")
527 }
David Crawshaw7b436622025-04-24 17:49:01 +0000528 } else {
Philip Zeyligerc5b8ed42025-05-05 20:28:34 +0000529 if s != nil {
530 s.AppendSystemMessage("skaband disconnected")
531 }
David Crawshaw7b436622025-04-24 17:49:01 +0000532 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700533 }
534 }
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000535 go skabandclient.DialAndServeLoop(ctx, flags.skabandAddr, flags.sessionID, pubKey, srv, connectFn)
Earl Lee2e463fb2025-04-17 11:22:22 -0700536 }
537
Philip Zeyligerc5b8ed42025-05-05 20:28:34 +0000538 // Handle one-shot mode or mode without terminal UI
539 if flags.oneShot || s == nil {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700540 it := agent.NewIterator(ctx, 0)
Earl Lee2e463fb2025-04-17 11:22:22 -0700541 for {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700542 m := it.Next()
543 if m == nil {
544 return nil
545 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700546 if m.Content != "" {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700547 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 -0700548 }
549 if m.EndOfTurn && m.ParentConversationID == nil {
550 fmt.Printf("Total cost: $%0.2f\n", agent.TotalUsage().TotalCostUSD)
Philip Zeyligerc5b8ed42025-05-05 20:28:34 +0000551 if flags.oneShot {
552 return nil
553 }
554 }
555 select {
556 case <-ctx.Done():
557 return ctx.Err()
558 default:
Earl Lee2e463fb2025-04-17 11:22:22 -0700559 }
560 }
561 }
Philip Zeyligerc5b8ed42025-05-05 20:28:34 +0000562 if s == nil {
563 panic("Should have exited above.")
564 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700565
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000566 // Run the terminal UI
Philip Zeyligerb74c4f62025-04-25 19:18:49 -0700567 defer func() {
568 r := recover()
569 if err := s.RestoreOldState(); err != nil {
570 fmt.Fprintf(os.Stderr, "couldn't restore old terminal state: %s\n", err)
571 }
572 if r != nil {
573 panic(r)
574 }
575 }()
Earl Lee2e463fb2025-04-17 11:22:22 -0700576 if err := s.Run(ctx); err != nil {
577 return err
578 }
579
580 return nil
581}
582
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000583// setupLogging configures the logging system based on command-line flags.
584// Returns the slog handler and optionally a log file (which should be closed by the caller).
Philip Zeyliger613c0f52025-05-15 16:36:22 -0700585func setupLogging(termui, verbose, unsafe bool) (slog.Handler, *os.File, error) {
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000586 var slogHandler slog.Handler
587 var logFile *os.File
588 var err error
589
Philip Zeyliger613c0f52025-05-15 16:36:22 -0700590 if verbose && !termui {
591 // Log to stderr
592 slogHandler = slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})
593 return slogHandler, nil, nil
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000594 }
595
Philip Zeyliger613c0f52025-05-15 16:36:22 -0700596 // Log to a file
597 logFile, err = os.CreateTemp("", "sketch-cli-log-*")
598 if err != nil {
599 return nil, nil, fmt.Errorf("cannot create log file: %v", err)
600 }
601 if unsafe {
602 fmt.Printf("structured logs: %v\n", logFile.Name())
603 }
604
Sean McCulloughd72563a2025-05-02 09:37:15 -0700605 slogHandler = slog.NewJSONHandler(logFile, &slog.HandlerOptions{Level: slog.LevelDebug})
606 slogHandler = skribe.AttrsWrap(slogHandler)
607
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000608 return slogHandler, logFile, nil
609}
610
Philip Zeyligerd1402952025-04-23 03:54:37 +0000611func getHostname() string {
612 hostname, err := os.Hostname()
613 if err != nil {
614 return "unknown"
615 }
616 return hostname
617}
618
Earl Lee2e463fb2025-04-17 11:22:22 -0700619func defaultGitUsername() string {
620 out, err := exec.Command("git", "config", "user.name").CombinedOutput()
621 if err != nil {
622 return "Sketch🕴️" // TODO: what should this be?
623 }
624 return strings.TrimSpace(string(out))
625}
626
627func defaultGitEmail() string {
628 out, err := exec.Command("git", "config", "user.email").CombinedOutput()
629 if err != nil {
630 return "skallywag@sketch.dev" // TODO: what should this be?
631 }
632 return strings.TrimSpace(string(out))
633}
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700634
635// selectLLMService creates an LLM service based on the specified model name.
636// If modelName is empty or "claude", it uses the Anthropic service.
David Crawshaw5a234062025-05-04 17:52:08 +0000637// If modelName is "gemini", it uses the Gemini service.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700638// Otherwise, it tries to use the OpenAI service with the specified model.
639// Returns an error if the model name is not recognized or if required configuration is missing.
David Crawshaw3659d872025-05-05 17:52:23 -0700640func selectLLMService(client *http.Client, modelName string, modelURL, apiKey string) (llm.Service, error) {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700641 if modelName == "" || modelName == "claude" {
642 if apiKey == "" {
643 return nil, fmt.Errorf("missing ANTHROPIC_API_KEY")
644 }
645 return &ant.Service{
646 HTTPC: client,
David Crawshaw3659d872025-05-05 17:52:23 -0700647 URL: modelURL,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700648 APIKey: apiKey,
649 }, nil
650 }
651
David Crawshaw5a234062025-05-04 17:52:08 +0000652 if modelName == "gemini" {
David Crawshaw5a234062025-05-04 17:52:08 +0000653 if apiKey == "" {
David Crawshaw961cc9e2025-05-05 14:33:33 -0700654 return nil, fmt.Errorf("missing %s", gem.GeminiAPIKeyEnv)
David Crawshaw5a234062025-05-04 17:52:08 +0000655 }
656 return &gem.Service{
657 HTTPC: client,
David Crawshaw3659d872025-05-05 17:52:23 -0700658 URL: modelURL,
David Crawshaw5a234062025-05-04 17:52:08 +0000659 Model: gem.DefaultModel,
660 APIKey: apiKey,
661 }, nil
662 }
663
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700664 model := oai.ModelByUserName(modelName)
665 if model == nil {
666 return nil, fmt.Errorf("unknown model '%s', use -list-models to see available models", modelName)
667 }
668
669 // Verify we have an API key, if necessary.
670 apiKey = os.Getenv(model.APIKeyEnv)
671 if model.APIKeyEnv != "" && apiKey == "" {
672 return nil, fmt.Errorf("missing API key for %s model, set %s environment variable", model.UserName, model.APIKeyEnv)
673 }
674
675 return &oai.Service{
676 HTTPC: client,
677 Model: *model,
678 APIKey: apiKey,
679 }, nil
680}