blob: ad81b740a35f10745abe4dd8e254d58930272830 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package main
2
3import (
Earl Lee2e463fb2025-04-17 11:22:22 -07004 "context"
5 "flag"
6 "fmt"
Earl Lee2e463fb2025-04-17 11:22:22 -07007 "log/slog"
8 "math/rand/v2"
9 "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
Earl Lee2e463fb2025-04-17 11:22:22 -070023 "github.com/richardlehane/crock32"
Josh Bleecher Snyder78707d62025-04-30 21:06:49 +000024 "sketch.dev/browser"
Earl Lee2e463fb2025-04-17 11:22:22 -070025 "sketch.dev/dockerimg"
26 "sketch.dev/httprr"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070027 "sketch.dev/llm/ant"
28 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070029 "sketch.dev/loop"
30 "sketch.dev/loop/server"
31 "sketch.dev/skabandclient"
32 "sketch.dev/skribe"
33 "sketch.dev/termui"
Philip Zeyligerc5b8ed42025-05-05 20:28:34 +000034
35 "golang.org/x/term"
Earl Lee2e463fb2025-04-17 11:22:22 -070036)
37
38func main() {
39 err := run()
40 if err != nil {
41 fmt.Fprintf(os.Stderr, "%v: %v\n", os.Args[0], err)
42 os.Exit(1)
43 }
44}
45
Sean McCulloughc8e4ab02025-05-02 16:11:37 +000046// run is the main entry point that parses flags and dispatches to the appropriate
47// execution path based on whether we're running in a container or not.
Earl Lee2e463fb2025-04-17 11:22:22 -070048func run() error {
Sean McCulloughc8e4ab02025-05-02 16:11:37 +000049 flagArgs := parseCLIFlags()
Philip Zeyligerd1402952025-04-23 03:54:37 +000050
Sean McCulloughc8e4ab02025-05-02 16:11:37 +000051 if flagArgs.version {
Earl Lee2e463fb2025-04-17 11:22:22 -070052 bi, ok := debug.ReadBuildInfo()
53 if ok {
54 fmt.Printf("%s@%v\n", bi.Path, bi.Main.Version)
55 }
56 return nil
57 }
58
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070059 if flagArgs.listModels {
60 fmt.Println("Available models:")
61 fmt.Println("- claude (default, uses Anthropic service)")
David Crawshaw5a234062025-05-04 17:52:08 +000062 fmt.Println("- gemini (uses Google Gemini 2.5 Pro service)")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070063 for _, name := range oai.ListModels() {
64 note := ""
65 if name != "gpt4.1" {
66 note = " (not recommended)"
67 }
68 fmt.Printf("- %s%s\n", name, note)
69 }
70 return nil
71 }
72
David Crawshaw5a234062025-05-04 17:52:08 +000073 // Claude and Gemini are supported in container mode
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070074 // TODO: finish support--thread through API keys, add server support
David Crawshaw5a234062025-05-04 17:52:08 +000075 isContainerSupported := flagArgs.modelName == "claude" || flagArgs.modelName == "" || flagArgs.modelName == "gemini"
76 if !isContainerSupported && (!flagArgs.unsafe || flagArgs.skabandAddr != "") {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070077 return fmt.Errorf("only -model=claude is supported in safe mode right now, use -unsafe -skaband-addr=''")
78 }
79
Josh Bleecher Snyderb4782142025-05-05 19:16:54 +000080 if err := flagArgs.experimentFlag.Process(); err != nil {
81 fmt.Fprintf(os.Stderr, "error parsing experimental flags: %v\n", err)
82 os.Exit(1)
83 }
84 if experiment.Enabled("list") {
85 experiment.Fprint(os.Stdout)
86 os.Exit(0)
87 }
88
Earl Lee2e463fb2025-04-17 11:22:22 -070089 // Add a global "session_id" to all logs using this context.
90 // A "session" is a single full run of the agent.
Sean McCulloughc8e4ab02025-05-02 16:11:37 +000091 ctx := skribe.ContextWithAttr(context.Background(), slog.String("session_id", flagArgs.sessionID))
Earl Lee2e463fb2025-04-17 11:22:22 -070092
Sean McCulloughc8e4ab02025-05-02 16:11:37 +000093 // Configure logging
94 slogHandler, logFile, err := setupLogging(flagArgs.oneShot, flagArgs.verbose, flagArgs.unsafe)
95 if err != nil {
96 return err
97 }
98 if logFile != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -070099 defer logFile.Close()
Earl Lee2e463fb2025-04-17 11:22:22 -0700100 }
101 slog.SetDefault(slog.New(slogHandler))
102
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000103 // Change to working directory if specified
104 if flagArgs.workingDir != "" {
105 if err := os.Chdir(flagArgs.workingDir); err != nil {
106 return fmt.Errorf("sketch: cannot change directory to %q: %v", flagArgs.workingDir, err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700107 }
108 }
109
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000110 // Set default git username and email if not provided
111 if flagArgs.gitUsername == "" {
112 flagArgs.gitUsername = defaultGitUsername()
Earl Lee2e463fb2025-04-17 11:22:22 -0700113 }
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000114 if flagArgs.gitEmail == "" {
115 flagArgs.gitEmail = defaultGitEmail()
Earl Lee2e463fb2025-04-17 11:22:22 -0700116 }
117
Philip Zeyliger6234a8d2025-05-02 14:31:20 -0700118 // Detect if we're inside the sketch container
119 inInsideSketch := flagArgs.outsideHostname != ""
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000120
121 // Validate initial commit and unsafe flag combination
122 if flagArgs.unsafe && flagArgs.initialCommit != "HEAD" {
123 return fmt.Errorf("cannot use -initial-commit with -unsafe, they are incompatible")
Earl Lee2e463fb2025-04-17 11:22:22 -0700124 }
125
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000126 // Dispatch to the appropriate execution path
Philip Zeyliger6234a8d2025-05-02 14:31:20 -0700127 if inInsideSketch {
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000128 // We're running inside the Docker container
Philip Zeyliger6234a8d2025-05-02 14:31:20 -0700129 return runInContainerMode(ctx, flagArgs, logFile)
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000130 } else if flagArgs.unsafe {
131 // We're running directly on the host in unsafe mode
132 return runInUnsafeMode(ctx, flagArgs, logFile)
133 } else {
134 // We're running on the host and need to launch a container
135 return runInHostMode(ctx, flagArgs)
136 }
137}
138
139// CLIFlags holds all command-line arguments
140type CLIFlags struct {
141 addr string
142 skabandAddr string
143 unsafe bool
144 openBrowser bool
145 httprrFile string
146 maxIterations uint64
147 maxWallTime time.Duration
148 maxDollars float64
149 oneShot bool
150 prompt string
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700151 modelName string
152 listModels bool
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000153 verbose bool
154 version bool
155 workingDir string
156 sshPort int
157 forceRebuild bool
158 initialCommit string
159 gitUsername string
160 gitEmail string
Josh Bleecher Snyderb4782142025-05-05 19:16:54 +0000161 experimentFlag experiment.Flag
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000162 sessionID string
163 record bool
164 noCleanup bool
165 containerLogDest string
166 outsideHostname string
167 outsideOS string
168 outsideWorkingDir string
169 sketchBinaryLinux string
Philip Zeyliger1dc21372025-05-05 19:54:44 +0000170 dockerArgs string
Philip Zeyligerc5b8ed42025-05-05 20:28:34 +0000171 termUI bool
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000172}
173
174// parseCLIFlags parses all command-line flags and returns a CLIFlags struct
175func parseCLIFlags() CLIFlags {
176 var flags CLIFlags
177
178 flag.StringVar(&flags.addr, "addr", "localhost:0", "local debug HTTP server address")
179 flag.StringVar(&flags.skabandAddr, "skaband-addr", "https://sketch.dev", "URL of the skaband server")
180 flag.BoolVar(&flags.unsafe, "unsafe", false, "run directly without a docker container")
181 flag.BoolVar(&flags.openBrowser, "open", true, "open sketch URL in system browser")
182 flag.StringVar(&flags.httprrFile, "httprr", "", "if set, record HTTP interactions to file")
183 flag.Uint64Var(&flags.maxIterations, "max-iterations", 0, "maximum number of iterations the agent should perform per turn, 0 to disable limit")
184 flag.DurationVar(&flags.maxWallTime, "max-wall-time", 0, "maximum time the agent should run per turn, 0 to disable limit")
185 flag.Float64Var(&flags.maxDollars, "max-dollars", 5.0, "maximum dollars the agent should spend per turn, 0 to disable limit")
186 flag.BoolVar(&flags.oneShot, "one-shot", false, "exit after the first turn without termui")
187 flag.StringVar(&flags.prompt, "prompt", "", "prompt to send to sketch")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700188 flag.StringVar(&flags.modelName, "model", "claude", "model to use (e.g. claude, gpt4.1)")
189 flag.BoolVar(&flags.listModels, "list-models", false, "list all available models and exit")
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000190 flag.BoolVar(&flags.verbose, "verbose", false, "enable verbose output")
191 flag.BoolVar(&flags.version, "version", false, "print the version and exit")
192 flag.StringVar(&flags.workingDir, "C", "", "when set, change to this directory before running")
193 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")
194 flag.BoolVar(&flags.forceRebuild, "force-rebuild-container", false, "rebuild Docker container")
195 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 +0000196 flag.StringVar(&flags.dockerArgs, "docker-args", "", "additional arguments to pass to the docker create command (e.g., --memory=2g --cpus=2)")
Philip Zeyligerc5b8ed42025-05-05 20:28:34 +0000197 flag.BoolVar(&flags.termUI, "termui", true, "enable terminal UI")
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000198
199 // Flags geared towards sketch developers or sketch internals:
200 flag.StringVar(&flags.gitUsername, "git-username", "", "(internal) username for git commits")
201 flag.StringVar(&flags.gitEmail, "git-email", "", "(internal) email for git commits")
202 flag.StringVar(&flags.sessionID, "session-id", newSessionID(), "(internal) unique session-id for a sketch process")
203 flag.BoolVar(&flags.record, "httprecord", true, "(debugging) Record trace (if httprr is set)")
204 flag.BoolVar(&flags.noCleanup, "nocleanup", false, "(debugging) do not clean up docker containers on exit")
205 flag.StringVar(&flags.containerLogDest, "save-container-logs", "", "(debugging) host path to save container logs to on exit")
206 flag.StringVar(&flags.outsideHostname, "outside-hostname", "", "(internal) hostname on the outside system")
207 flag.StringVar(&flags.outsideOS, "outside-os", "", "(internal) OS on the outside system")
208 flag.StringVar(&flags.outsideWorkingDir, "outside-working-dir", "", "(internal) working dir on the outside system")
209 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 +0000210 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 +0000211
212 flag.Parse()
Josh Bleecher Snydera23587b2025-05-07 12:42:53 +0000213
214 // -open's default value should be true normally but false in one-shot mode.
215 // Distinguish between -open default value vs explicitly set.
216 openExplicit := false
217 flag.Visit(func(f *flag.Flag) {
218 if f.Name == "open" {
219 openExplicit = true
220 }
221 })
222 if !openExplicit {
223 flags.openBrowser = !flags.oneShot
224 }
225
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000226 return flags
227}
228
229// runInHostMode handles execution on the host machine, which typically involves
230// checking host requirements and launching a Docker container.
231func runInHostMode(ctx context.Context, flags CLIFlags) error {
232 // Check host requirements
233 msgs, err := hostReqsCheck(flags.unsafe)
234 if flags.verbose {
235 fmt.Println("Host requirement checks:")
236 for _, m := range msgs {
237 fmt.Println(m)
238 }
239 }
240 if err != nil {
241 return err
242 }
243
244 // Get credentials and connect to skaband if needed
245 privKey, err := skabandclient.LoadOrCreatePrivateKey(skabandclient.DefaultKeyPath())
246 if err != nil {
247 return err
248 }
David Crawshaw5a7b3692025-05-05 16:49:15 -0700249 pubKey, modelURL, apiKey, err := skabandclient.Login(os.Stdout, privKey, flags.skabandAddr, flags.sessionID, flags.modelName)
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000250 if err != nil {
251 return err
252 }
253
254 // Get current working directory
255 cwd, err := os.Getwd()
256 if err != nil {
257 return fmt.Errorf("sketch: cannot determine current working directory: %v", err)
258 }
259
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000260 // Configure and launch the container
261 config := dockerimg.ContainerConfig{
262 SessionID: flags.sessionID,
263 LocalAddr: flags.addr,
264 SkabandAddr: flags.skabandAddr,
David Crawshaw5a7b3692025-05-05 16:49:15 -0700265 Model: flags.modelName,
266 ModelURL: modelURL,
267 ModelAPIKey: apiKey,
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000268 Path: cwd,
269 GitUsername: flags.gitUsername,
270 GitEmail: flags.gitEmail,
271 OpenBrowser: flags.openBrowser,
272 NoCleanup: flags.noCleanup,
273 ContainerLogDest: flags.containerLogDest,
274 SketchBinaryLinux: flags.sketchBinaryLinux,
275 SketchPubKey: pubKey,
276 SSHPort: flags.sshPort,
277 ForceRebuild: flags.forceRebuild,
278 OutsideHostname: getHostname(),
279 OutsideOS: runtime.GOOS,
280 OutsideWorkingDir: cwd,
281 OneShot: flags.oneShot,
282 Prompt: flags.prompt,
283 InitialCommit: flags.initialCommit,
David Crawshawb5f6a002025-05-05 08:27:16 -0700284 Verbose: flags.verbose,
Philip Zeyliger1dc21372025-05-05 19:54:44 +0000285 DockerArgs: flags.dockerArgs,
Josh Bleecher Snyderb1cca6f2025-05-06 01:52:55 +0000286 ExperimentFlag: flags.experimentFlag.String(),
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000287 }
288
David Crawshawb5f6a002025-05-05 08:27:16 -0700289 if err := dockerimg.LaunchContainer(ctx, config); err != nil {
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000290 if flags.verbose {
David Crawshawb5f6a002025-05-05 08:27:16 -0700291 fmt.Fprintf(os.Stderr, "dockerimg launch container failed: %v\n", err)
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000292 }
293 return err
294 }
295
296 return nil
297}
298
299// runInContainerMode handles execution inside the Docker container.
300// The inInsideSketch parameter indicates whether we're inside the sketch container
301// with access to outside environment variables.
Philip Zeyliger6234a8d2025-05-02 14:31:20 -0700302func runInContainerMode(ctx context.Context, flags CLIFlags, logFile *os.File) error {
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000303 // Get credentials from environment
David Crawshaw3659d872025-05-05 17:52:23 -0700304 apiKey := os.Getenv("SKETCH_MODEL_API_KEY")
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000305 pubKey := os.Getenv("SKETCH_PUB_KEY")
David Crawshaw3659d872025-05-05 17:52:23 -0700306 modelURL, err := skabandclient.LocalhostToDockerInternal(os.Getenv("SKETCH_MODEL_URL"))
307 if err != nil && os.Getenv("SKETCH_MODEL_URL") != "" {
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000308 return err
309 }
310
David Crawshaw3659d872025-05-05 17:52:23 -0700311 return setupAndRunAgent(ctx, flags, modelURL, apiKey, pubKey, true, logFile)
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000312}
313
314// runInUnsafeMode handles execution on the host machine without Docker.
315// This mode is used when the -unsafe flag is provided.
316func runInUnsafeMode(ctx context.Context, flags CLIFlags, logFile *os.File) error {
317 // Check if we need to get the API key from environment
318 var apiKey, antURL, pubKey string
319
320 if flags.skabandAddr == "" {
David Crawshaw961cc9e2025-05-05 14:33:33 -0700321 envName := "ANTHROPIC_API_KEY"
322 if flags.modelName == "gemini" {
323 envName = gem.GeminiAPIKeyEnv
324 }
325 apiKey = os.Getenv(envName)
Earl Lee2e463fb2025-04-17 11:22:22 -0700326 if apiKey == "" {
David Crawshaw961cc9e2025-05-05 14:33:33 -0700327 return fmt.Errorf("%s environment variable is not set", envName)
Earl Lee2e463fb2025-04-17 11:22:22 -0700328 }
329 } else {
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000330 // Connect to skaband
331 privKey, err := skabandclient.LoadOrCreatePrivateKey(skabandclient.DefaultKeyPath())
Earl Lee2e463fb2025-04-17 11:22:22 -0700332 if err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700333 return err
334 }
David Crawshaw961cc9e2025-05-05 14:33:33 -0700335 pubKey, antURL, apiKey, err = skabandclient.Login(os.Stdout, privKey, flags.skabandAddr, flags.sessionID, flags.modelName)
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000336 if err != nil {
337 return err
338 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700339 }
340
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000341 return setupAndRunAgent(ctx, flags, antURL, apiKey, pubKey, false, logFile)
342}
343
344// setupAndRunAgent handles the common logic for setting up and running the agent
345// in both container and unsafe modes.
David Crawshaw3659d872025-05-05 17:52:23 -0700346func setupAndRunAgent(ctx context.Context, flags CLIFlags, modelURL, apiKey, pubKey string, inInsideSketch bool, logFile *os.File) error {
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000347 // Configure HTTP client with optional recording
Earl Lee2e463fb2025-04-17 11:22:22 -0700348 var client *http.Client
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000349 if flags.httprrFile != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -0700350 var err error
351 var rr *httprr.RecordReplay
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000352 if flags.record {
353 rr, err = httprr.OpenForRecording(flags.httprrFile, http.DefaultTransport)
Earl Lee2e463fb2025-04-17 11:22:22 -0700354 } else {
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000355 rr, err = httprr.Open(flags.httprrFile, http.DefaultTransport)
Earl Lee2e463fb2025-04-17 11:22:22 -0700356 }
357 if err != nil {
358 return fmt.Errorf("httprr: %v", err)
359 }
360 // Scrub API keys from requests for security
361 rr.ScrubReq(func(req *http.Request) error {
362 req.Header.Del("x-api-key")
363 req.Header.Del("anthropic-api-key")
364 return nil
365 })
366 client = rr.Client()
367 }
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000368
Earl Lee2e463fb2025-04-17 11:22:22 -0700369 wd, err := os.Getwd()
370 if err != nil {
371 return err
372 }
373
David Crawshaw3659d872025-05-05 17:52:23 -0700374 llmService, err := selectLLMService(client, flags.modelName, modelURL, apiKey)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700375 if err != nil {
376 return fmt.Errorf("failed to initialize LLM service: %w", err)
377 }
378 budget := conversation.Budget{
379 MaxResponses: flags.maxIterations,
380 MaxWallTime: flags.maxWallTime,
381 MaxDollars: flags.maxDollars,
382 }
383
Earl Lee2e463fb2025-04-17 11:22:22 -0700384 agentConfig := loop.AgentConfig{
Philip Zeyliger18532b22025-04-23 21:11:46 +0000385 Context: ctx,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700386 Service: llmService,
387 Budget: budget,
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000388 GitUsername: flags.gitUsername,
389 GitEmail: flags.gitEmail,
390 SessionID: flags.sessionID,
Philip Zeyliger18532b22025-04-23 21:11:46 +0000391 ClientGOOS: runtime.GOOS,
392 ClientGOARCH: runtime.GOARCH,
393 UseAnthropicEdit: os.Getenv("SKETCH_ANTHROPIC_EDIT") == "1",
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000394 OutsideHostname: flags.outsideHostname,
395 OutsideOS: flags.outsideOS,
396 OutsideWorkingDir: flags.outsideWorkingDir,
Philip Zeyliger49edc922025-05-14 09:45:45 -0700397 // Ultimately this is a subtle flag because it's trying to distinguish
398 // between unsafe-on-host and inside sketch, and should probably be renamed/simplified.
399 InDocker: flags.outsideHostname != "",
400 OneShot: flags.oneShot,
Earl Lee2e463fb2025-04-17 11:22:22 -0700401 }
402 agent := loop.NewAgent(agentConfig)
403
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000404 // Create the server
Earl Lee2e463fb2025-04-17 11:22:22 -0700405 srv, err := server.New(agent, logFile)
406 if err != nil {
407 return err
408 }
409
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000410 // Initialize the agent (only needed when not inside sketch with outside hostname)
Philip Zeyliger5fdd0242025-04-25 19:31:58 -0700411 if !inInsideSketch {
Earl Lee2e463fb2025-04-17 11:22:22 -0700412 ini := loop.AgentInit{
413 WorkingDir: wd,
414 }
415 if err = agent.Init(ini); err != nil {
416 return fmt.Errorf("failed to initialize agent: %v", err)
417 }
418 }
419
420 // Start the agent
421 go agent.Loop(ctx)
422
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000423 // Start the local HTTP server
424 ln, err := net.Listen("tcp", flags.addr)
Earl Lee2e463fb2025-04-17 11:22:22 -0700425 if err != nil {
426 return fmt.Errorf("cannot create debug server listener: %v", err)
427 }
428 go (&http.Server{Handler: srv}).Serve(ln)
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000429
430 // Determine the URL to display
Earl Lee2e463fb2025-04-17 11:22:22 -0700431 var ps1URL string
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000432 if flags.skabandAddr != "" {
433 ps1URL = fmt.Sprintf("%s/s/%s", flags.skabandAddr, flags.sessionID)
434 } else if !agentConfig.InDocker {
Earl Lee2e463fb2025-04-17 11:22:22 -0700435 // Do not tell users about the port inside the container, let the
436 // process running on the host report this.
437 ps1URL = fmt.Sprintf("http://%s", ln.Addr())
438 }
439
Philip Zeyliger5fdd0242025-04-25 19:31:58 -0700440 if inInsideSketch {
Earl Lee2e463fb2025-04-17 11:22:22 -0700441 <-agent.Ready()
442 if ps1URL == "" {
443 ps1URL = agent.URL()
444 }
445 }
446
Pokey Rule0dcebe12025-04-28 14:51:04 +0100447 // Use prompt if provided
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000448 if flags.prompt != "" {
449 agent.UserMessage(ctx, flags.prompt)
Philip Zeyligerb74c4f62025-04-25 19:18:49 -0700450 }
451
Philip Zeyliger6ed6adb2025-04-23 19:56:38 -0700452 // Open the web UI URL in the system browser if requested
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000453 if flags.openBrowser {
Josh Bleecher Snydere54b00a2025-04-30 16:48:02 -0700454 browser.Open(ps1URL)
Earl Lee2e463fb2025-04-17 11:22:22 -0700455 }
456
Philip Zeyligerc5b8ed42025-05-05 20:28:34 +0000457 // Check if terminal UI should be enabled
458 // Disable termui if the flag is explicitly set to false or if we detect no PTY is available
459 if !term.IsTerminal(int(os.Stdin.Fd())) {
460 flags.termUI = false
461 }
462
463 // Create a variable for terminal UI
464 var s *termui.TermUI
465
466 // Create the termui instance only if needed
467 if flags.termUI {
468 s = termui.New(agent, ps1URL)
469 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700470
471 // Start skaband connection loop if needed
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000472 if flags.skabandAddr != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -0700473 connectFn := func(connected bool) {
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000474 if flags.verbose {
David Crawshaw7b436622025-04-24 17:49:01 +0000475 if connected {
Philip Zeyligerc5b8ed42025-05-05 20:28:34 +0000476 if s != nil {
477 s.AppendSystemMessage("skaband connected")
478 }
David Crawshaw7b436622025-04-24 17:49:01 +0000479 } else {
Philip Zeyligerc5b8ed42025-05-05 20:28:34 +0000480 if s != nil {
481 s.AppendSystemMessage("skaband disconnected")
482 }
David Crawshaw7b436622025-04-24 17:49:01 +0000483 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700484 }
485 }
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000486 go skabandclient.DialAndServeLoop(ctx, flags.skabandAddr, flags.sessionID, pubKey, srv, connectFn)
Earl Lee2e463fb2025-04-17 11:22:22 -0700487 }
488
Philip Zeyligerc5b8ed42025-05-05 20:28:34 +0000489 // Handle one-shot mode or mode without terminal UI
490 if flags.oneShot || s == nil {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700491 it := agent.NewIterator(ctx, 0)
Earl Lee2e463fb2025-04-17 11:22:22 -0700492 for {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700493 m := it.Next()
494 if m == nil {
495 return nil
496 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700497 if m.Content != "" {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700498 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 -0700499 }
500 if m.EndOfTurn && m.ParentConversationID == nil {
501 fmt.Printf("Total cost: $%0.2f\n", agent.TotalUsage().TotalCostUSD)
Philip Zeyligerc5b8ed42025-05-05 20:28:34 +0000502 if flags.oneShot {
503 return nil
504 }
505 }
506 select {
507 case <-ctx.Done():
508 return ctx.Err()
509 default:
Earl Lee2e463fb2025-04-17 11:22:22 -0700510 }
511 }
512 }
Philip Zeyligerc5b8ed42025-05-05 20:28:34 +0000513 if s == nil {
514 panic("Should have exited above.")
515 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700516
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000517 // Run the terminal UI
Philip Zeyligerb74c4f62025-04-25 19:18:49 -0700518 defer func() {
519 r := recover()
520 if err := s.RestoreOldState(); err != nil {
521 fmt.Fprintf(os.Stderr, "couldn't restore old terminal state: %s\n", err)
522 }
523 if r != nil {
524 panic(r)
525 }
526 }()
Earl Lee2e463fb2025-04-17 11:22:22 -0700527 if err := s.Run(ctx); err != nil {
528 return err
529 }
530
531 return nil
532}
533
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000534// setupLogging configures the logging system based on command-line flags.
535// Returns the slog handler and optionally a log file (which should be closed by the caller).
536func setupLogging(oneShot, verbose, unsafe bool) (slog.Handler, *os.File, error) {
537 var slogHandler slog.Handler
538 var logFile *os.File
539 var err error
540
541 if !oneShot && !verbose {
542 // Log to a file
543 logFile, err = os.CreateTemp("", "sketch-cli-log-*")
544 if err != nil {
545 return nil, nil, fmt.Errorf("cannot create log file: %v", err)
546 }
547 if unsafe {
548 fmt.Printf("structured logs: %v\n", logFile.Name())
549 }
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000550 }
551
Sean McCulloughd72563a2025-05-02 09:37:15 -0700552 // Always send slogs to the logFile.
553 slogHandler = slog.NewJSONHandler(logFile, &slog.HandlerOptions{Level: slog.LevelDebug})
554 slogHandler = skribe.AttrsWrap(slogHandler)
555
Sean McCulloughc8e4ab02025-05-02 16:11:37 +0000556 return slogHandler, logFile, nil
557}
558
Earl Lee2e463fb2025-04-17 11:22:22 -0700559// newSessionID generates a new 10-byte random Session ID.
560func newSessionID() string {
561 u1, u2 := rand.Uint64(), rand.Uint64N(1<<16)
562 s := crock32.Encode(u1) + crock32.Encode(uint64(u2))
563 if len(s) < 16 {
564 s += strings.Repeat("0", 16-len(s))
565 }
566 return s[0:4] + "-" + s[4:8] + "-" + s[8:12] + "-" + s[12:16]
567}
568
Philip Zeyligerd1402952025-04-23 03:54:37 +0000569func getHostname() string {
570 hostname, err := os.Hostname()
571 if err != nil {
572 return "unknown"
573 }
574 return hostname
575}
576
Earl Lee2e463fb2025-04-17 11:22:22 -0700577func defaultGitUsername() string {
578 out, err := exec.Command("git", "config", "user.name").CombinedOutput()
579 if err != nil {
580 return "Sketch🕴️" // TODO: what should this be?
581 }
582 return strings.TrimSpace(string(out))
583}
584
585func defaultGitEmail() string {
586 out, err := exec.Command("git", "config", "user.email").CombinedOutput()
587 if err != nil {
588 return "skallywag@sketch.dev" // TODO: what should this be?
589 }
590 return strings.TrimSpace(string(out))
591}
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700592
593// selectLLMService creates an LLM service based on the specified model name.
594// If modelName is empty or "claude", it uses the Anthropic service.
David Crawshaw5a234062025-05-04 17:52:08 +0000595// If modelName is "gemini", it uses the Gemini service.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700596// Otherwise, it tries to use the OpenAI service with the specified model.
597// Returns an error if the model name is not recognized or if required configuration is missing.
David Crawshaw3659d872025-05-05 17:52:23 -0700598func selectLLMService(client *http.Client, modelName string, modelURL, apiKey string) (llm.Service, error) {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700599 if modelName == "" || modelName == "claude" {
600 if apiKey == "" {
601 return nil, fmt.Errorf("missing ANTHROPIC_API_KEY")
602 }
603 return &ant.Service{
604 HTTPC: client,
David Crawshaw3659d872025-05-05 17:52:23 -0700605 URL: modelURL,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700606 APIKey: apiKey,
607 }, nil
608 }
609
David Crawshaw5a234062025-05-04 17:52:08 +0000610 if modelName == "gemini" {
David Crawshaw5a234062025-05-04 17:52:08 +0000611 if apiKey == "" {
David Crawshaw961cc9e2025-05-05 14:33:33 -0700612 return nil, fmt.Errorf("missing %s", gem.GeminiAPIKeyEnv)
David Crawshaw5a234062025-05-04 17:52:08 +0000613 }
614 return &gem.Service{
615 HTTPC: client,
David Crawshaw3659d872025-05-05 17:52:23 -0700616 URL: modelURL,
David Crawshaw5a234062025-05-04 17:52:08 +0000617 Model: gem.DefaultModel,
618 APIKey: apiKey,
619 }, nil
620 }
621
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700622 model := oai.ModelByUserName(modelName)
623 if model == nil {
624 return nil, fmt.Errorf("unknown model '%s', use -list-models to see available models", modelName)
625 }
626
627 // Verify we have an API key, if necessary.
628 apiKey = os.Getenv(model.APIKeyEnv)
629 if model.APIKeyEnv != "" && apiKey == "" {
630 return nil, fmt.Errorf("missing API key for %s model, set %s environment variable", model.UserName, model.APIKeyEnv)
631 }
632
633 return &oai.Service{
634 HTTPC: client,
635 Model: *model,
636 APIKey: apiKey,
637 }, nil
638}