blob: 9d8023c71b0e934e01c69bac9305e7970df2ffb7 [file] [log] [blame]
package main
import (
"cmp"
"context"
"flag"
"fmt"
"io"
"io/fs"
"log/slog"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"runtime/debug"
"strings"
"sketch.dev/experiment"
"sketch.dev/llm"
"sketch.dev/llm/gem"
"sketch.dev/llm/oai"
"sketch.dev/browser"
"sketch.dev/dockerimg"
"sketch.dev/httprr"
"sketch.dev/llm/ant"
"sketch.dev/llm/conversation"
"sketch.dev/loop"
"sketch.dev/loop/server"
"sketch.dev/skabandclient"
"sketch.dev/skribe"
"sketch.dev/termui"
"sketch.dev/webui"
"golang.org/x/term"
)
func main() {
err := run()
if err != nil {
fmt.Fprintf(os.Stderr, "%v: %v\n", os.Args[0], err)
os.Exit(1)
}
}
// run is the main entry point that parses flags and dispatches to the appropriate
// execution path based on whether we're running in a container or not.
func run() error {
flagArgs := parseCLIFlags()
if flagArgs.version {
bi, ok := debug.ReadBuildInfo()
if ok {
fmt.Printf("%s@%v\n", bi.Path, bi.Main.Version)
}
return nil
}
if flagArgs.listModels {
fmt.Println("Available models:")
fmt.Println("- claude (default, uses Anthropic service)")
fmt.Println("- gemini (uses Google Gemini 2.5 Pro service)")
for _, name := range oai.ListModels() {
note := ""
if name != "gpt4.1" {
note = " (not recommended)"
}
fmt.Printf("- %s%s\n", name, note)
}
return nil
}
if flagArgs.dumpDist != "" {
return dumpDistFilesystem(flagArgs.dumpDist)
}
// Claude and Gemini are supported in container mode
// TODO: finish support--thread through API keys, add server support
isContainerSupported := flagArgs.modelName == "claude" || flagArgs.modelName == "" || flagArgs.modelName == "gemini"
if !isContainerSupported && (!flagArgs.unsafe || flagArgs.skabandAddr != "") {
return fmt.Errorf("only -model=claude is supported in safe mode right now, use -unsafe -skaband-addr=''")
}
if err := flagArgs.experimentFlag.Process(); err != nil {
fmt.Fprintf(os.Stderr, "error parsing experimental flags: %v\n", err)
os.Exit(1)
}
if experiment.Enabled("list") {
experiment.Fprint(os.Stdout)
os.Exit(0)
}
// Add a global "session_id" to all logs using this context.
// A "session" is a single full run of the agent.
ctx := skribe.ContextWithAttr(context.Background(), slog.String("session_id", flagArgs.sessionID))
// Configure logging
slogHandler, logFile, err := setupLogging(flagArgs.termUI, flagArgs.verbose, flagArgs.unsafe)
if err != nil {
return err
}
if logFile != nil {
defer logFile.Close()
}
slog.SetDefault(slog.New(slogHandler))
// Change to working directory if specified
if flagArgs.workingDir != "" {
if err := os.Chdir(flagArgs.workingDir); err != nil {
return fmt.Errorf("sketch: cannot change directory to %q: %v", flagArgs.workingDir, err)
}
}
// Set default git username and email if not provided
if flagArgs.gitUsername == "" {
flagArgs.gitUsername = defaultGitUsername()
}
if flagArgs.gitEmail == "" {
flagArgs.gitEmail = defaultGitEmail()
}
// Detect if we're inside the sketch container
inInsideSketch := flagArgs.outsideHostname != ""
// Dispatch to the appropriate execution path
if inInsideSketch {
// We're running inside the Docker container
return runInContainerMode(ctx, flagArgs, logFile)
} else if flagArgs.unsafe {
// We're running directly on the host in unsafe mode
return runInUnsafeMode(ctx, flagArgs, logFile)
} else {
// We're running on the host and need to launch a container
return runInHostMode(ctx, flagArgs)
}
}
// expandTilde expands ~ in the given path to the user's home directory
func expandTilde(path string) (string, error) {
if path == "~" {
homeDir, err := os.UserHomeDir()
if err != nil {
return path, err
}
return homeDir, nil
}
if strings.HasPrefix(path, "~/") {
homeDir, err := os.UserHomeDir()
if err != nil {
return path, err
}
return strings.Replace(path, "~", homeDir, 1), nil
}
return path, nil
}
// CLIFlags holds all command-line arguments
// StringSliceFlag is a custom flag type that allows for repeated flag values.
// It collects all values into a slice.
type StringSliceFlag []string
// String returns the string representation of the flag value.
func (f *StringSliceFlag) String() string {
return strings.Join(*f, ",")
}
// Set adds a value to the flag.
func (f *StringSliceFlag) Set(value string) error {
*f = append(*f, value)
return nil
}
// Get returns the flag values.
func (f *StringSliceFlag) Get() any {
return []string(*f)
}
type CLIFlags struct {
addr string
skabandAddr string
unsafe bool
openBrowser bool
httprrFile string
maxDollars float64
oneShot bool
prompt string
modelName string
llmAPIKey string
listModels bool
verbose bool
version bool
workingDir string
dumpDist string
sshPort int
forceRebuild bool
linkToGitHub bool
gitUsername string
gitEmail string
experimentFlag experiment.Flag
sessionID string
record bool
noCleanup bool
containerLogDest string
outsideHostname string
outsideOS string
outsideWorkingDir string
sketchBinaryLinux string
dockerArgs string
mounts StringSliceFlag
termUI bool
gitRemoteURL string
upstream string
commit string
outsideHTTP string
branchPrefix string
sshConnectionString string
}
// parseCLIFlags parses all command-line flags and returns a CLIFlags struct
func parseCLIFlags() CLIFlags {
var flags CLIFlags
// Create separate flagsets for user-visible and internal flags
userFlags := flag.NewFlagSet("sketch", flag.ExitOnError)
internalFlags := flag.NewFlagSet("sketch-internal", flag.ContinueOnError)
// User-visible flags
userFlags.StringVar(&flags.addr, "addr", "localhost:0", "local HTTP server")
userFlags.StringVar(&flags.skabandAddr, "skaband-addr", "https://sketch.dev", "URL of the skaband server; set to empty to disable sketch.dev integration")
userFlags.BoolVar(&flags.unsafe, "unsafe", false, "run without a docker container")
userFlags.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")
userFlags.Float64Var(&flags.maxDollars, "max-dollars", 10.0, "maximum dollars the agent should spend per turn, 0 to disable limit")
userFlags.BoolVar(&flags.oneShot, "one-shot", false, "exit after the first turn without termui")
userFlags.StringVar(&flags.prompt, "prompt", "", "prompt to send to sketch")
userFlags.StringVar(&flags.prompt, "p", "", "prompt to send to sketch (alias for -prompt)")
userFlags.StringVar(&flags.modelName, "model", "claude", "model to use (e.g. claude, gpt4.1)")
userFlags.StringVar(&flags.llmAPIKey, "llm-api-key", "", "API key for the LLM provider; if not set, will be read from an env var")
userFlags.BoolVar(&flags.listModels, "list-models", false, "list all available models and exit")
userFlags.BoolVar(&flags.verbose, "verbose", false, "enable verbose output")
userFlags.BoolVar(&flags.version, "version", false, "print the version and exit")
userFlags.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")
userFlags.BoolVar(&flags.forceRebuild, "force-rebuild-container", false, "rebuild Docker container")
userFlags.StringVar(&flags.dockerArgs, "docker-args", "", "additional arguments to pass to the docker create command (e.g., --memory=2g --cpus=2)")
userFlags.Var(&flags.mounts, "mount", "volume to mount in the container in format /path/on/host:/path/in/container (can be repeated)")
userFlags.BoolVar(&flags.termUI, "termui", true, "enable terminal UI")
userFlags.StringVar(&flags.branchPrefix, "branch-prefix", "sketch/", "prefix for git branches created by sketch")
// Internal flags (for sketch developers or internal use)
// Args to sketch innie:
internalFlags.StringVar(&flags.gitUsername, "git-username", "", "(internal) username for git commits")
internalFlags.StringVar(&flags.gitEmail, "git-email", "", "(internal) email for git commits")
internalFlags.StringVar(&flags.sessionID, "session-id", skabandclient.NewSessionID(), "(internal) unique session-id for a sketch process")
internalFlags.BoolVar(&flags.record, "httprecord", true, "(debugging) Record trace (if httprr is set)")
internalFlags.BoolVar(&flags.noCleanup, "nocleanup", false, "(debugging) do not clean up docker containers on exit")
internalFlags.StringVar(&flags.containerLogDest, "save-container-logs", "", "(debugging) host path to save container logs to on exit")
internalFlags.StringVar(&flags.outsideHostname, "outside-hostname", "", "(internal) hostname on the outside system")
internalFlags.StringVar(&flags.outsideOS, "outside-os", "", "(internal) OS on the outside system")
internalFlags.StringVar(&flags.outsideWorkingDir, "outside-working-dir", "", "(internal) working dir on the outside system")
internalFlags.StringVar(&flags.sketchBinaryLinux, "sketch-binary-linux", "", "(development) path to a pre-built sketch binary for linux")
internalFlags.StringVar(&flags.gitRemoteURL, "git-remote-url", "", "(internal) git remote for outside sketch")
internalFlags.StringVar(&flags.upstream, "upstream", "", "(internal) upstream branch for git work")
internalFlags.StringVar(&flags.commit, "commit", "", "(internal) the git commit reference to check out from git remote url")
internalFlags.StringVar(&flags.outsideHTTP, "outside-http", "", "(internal) host for outside sketch")
internalFlags.BoolVar(&flags.linkToGitHub, "link-to-github", false, "(internal) enable GitHub branch linking in UI")
internalFlags.StringVar(&flags.sshConnectionString, "ssh-connection-string", "", "(internal) SSH connection string for connecting to the container")
// Developer flags
internalFlags.StringVar(&flags.httprrFile, "httprr", "", "if set, record HTTP interactions to file")
internalFlags.Var(&flags.experimentFlag, "x", "enable experimental features (comma-separated list or repeat flag; use 'list' to show all)")
// This is really only useful for someone running with "go run"
userFlags.StringVar(&flags.workingDir, "C", "", "when set, change to this directory before running")
// Internal flags for development/debugging
internalFlags.StringVar(&flags.dumpDist, "dump-dist", "", "(internal) dump embedded /dist/ filesystem to specified directory and exit")
// Custom usage function that shows only user-visible flags by default
userFlags.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0])
userFlags.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nFor additional internal/debugging flags, use -help-internal\n")
}
// Check if user requested internal help
if len(os.Args) > 1 && os.Args[1] == "-help-internal" {
fmt.Fprintf(os.Stderr, "Internal/debugging flags for %s:\n", os.Args[0])
internalFlags.PrintDefaults()
os.Exit(0)
}
// Create a combined flagset for actual parsing by merging the two flagsets
allFlags := flag.NewFlagSet("sketch-all", flag.ExitOnError)
allFlags.Usage = userFlags.Usage
// Copy all flags from userFlags to allFlags
userFlags.VisitAll(func(f *flag.Flag) {
allFlags.Var(f.Value, f.Name, f.Usage)
})
// Copy all flags from internalFlags to allFlags
internalFlags.VisitAll(func(f *flag.Flag) {
allFlags.Var(f.Value, f.Name, f.Usage)
})
// Parse all arguments with the combined flagset
allFlags.Parse(os.Args[1:])
// -open's default value is not a simple true/false; it depends on other flags and conditions.
// Distinguish between -open default value vs explicitly set.
openExplicit := false
allFlags.Visit(func(f *flag.Flag) {
if f.Name == "open" {
openExplicit = true
}
})
if !openExplicit {
// Not explicitly set.
// Calculate the right default value: true except with one-shot mode or if we're running in a ssh session.
flags.openBrowser = !flags.oneShot && os.Getenv("SSH_CONNECTION") == ""
}
// expand ~ in mounts
for i, mount := range flags.mounts {
host, container, ok := strings.Cut(mount, ":")
if !ok {
continue
}
expanded, err := expandTilde(host)
if err != nil {
slog.Warn("failed to expand tilde in mount path", "path", host, "error", err)
continue
}
flags.mounts[i] = expanded + ":" + container
}
return flags
}
// runInHostMode handles execution on the host machine, which typically involves
// checking host requirements and launching a Docker container.
func runInHostMode(ctx context.Context, flags CLIFlags) error {
// Check host requirements
msgs, err := hostReqsCheck(flags.unsafe)
if flags.verbose {
fmt.Println("Host requirement checks:")
for _, m := range msgs {
fmt.Println(m)
}
}
if err != nil {
return err
}
// Get credentials and connect to skaband if needed
var pubKey, modelURL, apiKey string
if flags.skabandAddr != "" {
privKey, err := skabandclient.LoadOrCreatePrivateKey(skabandclient.DefaultKeyPath())
if err != nil {
return err
}
pubKey, modelURL, apiKey, err = skabandclient.Login(os.Stdout, privKey, flags.skabandAddr, flags.sessionID, flags.modelName)
if err != nil {
return err
}
} else {
// When not using skaband, get API key from environment or flag
envName := "ANTHROPIC_API_KEY"
if flags.modelName == "gemini" {
envName = gem.GeminiAPIKeyEnv
}
apiKey = cmp.Or(os.Getenv(envName), flags.llmAPIKey)
if apiKey == "" {
return fmt.Errorf("%s environment variable is not set, -llm-api-key flag not provided", envName)
}
}
// Get current working directory
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("sketch: cannot determine current working directory: %v", err)
}
// Configure and launch the container
config := dockerimg.ContainerConfig{
SessionID: flags.sessionID,
LocalAddr: flags.addr,
SkabandAddr: flags.skabandAddr,
Model: flags.modelName,
ModelURL: modelURL,
ModelAPIKey: apiKey,
Path: cwd,
GitUsername: flags.gitUsername,
GitEmail: flags.gitEmail,
OpenBrowser: flags.openBrowser,
NoCleanup: flags.noCleanup,
ContainerLogDest: flags.containerLogDest,
SketchBinaryLinux: flags.sketchBinaryLinux,
SketchPubKey: pubKey,
SSHPort: flags.sshPort,
ForceRebuild: flags.forceRebuild,
OutsideHostname: getHostname(),
OutsideOS: runtime.GOOS,
OutsideWorkingDir: cwd,
OneShot: flags.oneShot,
Prompt: flags.prompt,
Verbose: flags.verbose,
DockerArgs: flags.dockerArgs,
Mounts: flags.mounts,
ExperimentFlag: flags.experimentFlag.String(),
TermUI: flags.termUI,
MaxDollars: flags.maxDollars,
BranchPrefix: flags.branchPrefix,
LinkToGitHub: flags.linkToGitHub,
}
if err := dockerimg.LaunchContainer(ctx, config); err != nil {
if flags.verbose {
fmt.Fprintf(os.Stderr, "dockerimg launch container failed: %v\n", err)
}
return err
}
return nil
}
// runInContainerMode handles execution inside the Docker container.
// The inInsideSketch parameter indicates whether we're inside the sketch container
// with access to outside environment variables.
func runInContainerMode(ctx context.Context, flags CLIFlags, logFile *os.File) error {
// Get credentials from environment
apiKey := cmp.Or(os.Getenv("SKETCH_MODEL_API_KEY"), flags.llmAPIKey)
pubKey := os.Getenv("SKETCH_PUB_KEY")
modelURL, err := skabandclient.LocalhostToDockerInternal(os.Getenv("SKETCH_MODEL_URL"))
if err != nil && os.Getenv("SKETCH_MODEL_URL") != "" {
return err
}
return setupAndRunAgent(ctx, flags, modelURL, apiKey, pubKey, true, logFile)
}
// runInUnsafeMode handles execution on the host machine without Docker.
// This mode is used when the -unsafe flag is provided.
func runInUnsafeMode(ctx context.Context, flags CLIFlags, logFile *os.File) error {
// Check if we need to get the API key from environment
var apiKey, antURL, pubKey string
if flags.skabandAddr == "" {
envName := "ANTHROPIC_API_KEY"
if flags.modelName == "gemini" {
envName = gem.GeminiAPIKeyEnv
}
apiKey = cmp.Or(os.Getenv(envName), flags.llmAPIKey)
if apiKey == "" {
return fmt.Errorf("%s environment variable is not set, -llm-api-key flag not provided", envName)
}
} else {
// Connect to skaband
privKey, err := skabandclient.LoadOrCreatePrivateKey(skabandclient.DefaultKeyPath())
if err != nil {
return err
}
pubKey, antURL, apiKey, err = skabandclient.Login(os.Stdout, privKey, flags.skabandAddr, flags.sessionID, flags.modelName)
if err != nil {
return err
}
}
return setupAndRunAgent(ctx, flags, antURL, apiKey, pubKey, false, logFile)
}
// setupAndRunAgent handles the common logic for setting up and running the agent
// in both container and unsafe modes.
func setupAndRunAgent(ctx context.Context, flags CLIFlags, modelURL, apiKey, pubKey string, inInsideSketch bool, logFile *os.File) error {
// Configure HTTP client with optional recording
var client *http.Client
if flags.httprrFile != "" {
var err error
var rr *httprr.RecordReplay
if flags.record {
rr, err = httprr.OpenForRecording(flags.httprrFile, http.DefaultTransport)
} else {
rr, err = httprr.Open(flags.httprrFile, http.DefaultTransport)
}
if err != nil {
return fmt.Errorf("httprr: %v", err)
}
// Scrub API keys from requests for security
rr.ScrubReq(func(req *http.Request) error {
req.Header.Del("x-api-key")
req.Header.Del("anthropic-api-key")
return nil
})
client = rr.Client()
}
wd, err := os.Getwd()
if err != nil {
return err
}
llmService, err := selectLLMService(client, flags.modelName, modelURL, apiKey)
if err != nil {
return fmt.Errorf("failed to initialize LLM service: %w", err)
}
budget := conversation.Budget{
MaxDollars: flags.maxDollars,
}
agentConfig := loop.AgentConfig{
Context: ctx,
Service: llmService,
Budget: budget,
GitUsername: flags.gitUsername,
GitEmail: flags.gitEmail,
SessionID: flags.sessionID,
ClientGOOS: runtime.GOOS,
ClientGOARCH: runtime.GOARCH,
OutsideHostname: flags.outsideHostname,
OutsideOS: flags.outsideOS,
OutsideWorkingDir: flags.outsideWorkingDir,
WorkingDir: wd,
// Ultimately this is a subtle flag because it's trying to distinguish
// between unsafe-on-host and inside sketch, and should probably be renamed/simplified.
InDocker: flags.outsideHostname != "",
OneShot: flags.oneShot,
GitRemoteAddr: flags.gitRemoteURL,
Upstream: flags.upstream,
OutsideHTTP: flags.outsideHTTP,
Commit: flags.commit,
BranchPrefix: flags.branchPrefix,
LinkToGitHub: flags.linkToGitHub,
SSHConnectionString: flags.sshConnectionString,
}
// Create SkabandClient if skaband address is provided
if flags.skabandAddr != "" && pubKey != "" {
agentConfig.SkabandClient = skabandclient.NewSkabandClient(flags.skabandAddr, pubKey)
}
agent := loop.NewAgent(agentConfig)
// Create the server
srv, err := server.New(agent, logFile)
if err != nil {
return err
}
// Initialize the agent (only needed when not inside sketch with outside hostname)
// In the innie case, outtie sends a POST /init
if !inInsideSketch {
if err = agent.Init(loop.AgentInit{}); err != nil {
return fmt.Errorf("failed to initialize agent: %v", err)
}
}
// Start the agent
go agent.Loop(ctx)
// Start the local HTTP server
ln, err := net.Listen("tcp", flags.addr)
if err != nil {
return fmt.Errorf("cannot create debug server listener: %v", err)
}
go (&http.Server{Handler: srv}).Serve(ln)
// Determine the URL to display
var ps1URL string
if flags.skabandAddr != "" {
ps1URL = fmt.Sprintf("%s/s/%s", flags.skabandAddr, flags.sessionID)
} else if !agentConfig.InDocker {
// Do not tell users about the port inside the container, let the
// process running on the host report this.
ps1URL = fmt.Sprintf("http://%s", ln.Addr())
}
if inInsideSketch {
<-agent.Ready()
if ps1URL == "" {
ps1URL = agent.URL()
}
}
// Use prompt if provided
if flags.prompt != "" {
agent.UserMessage(ctx, flags.prompt)
}
// Open the web UI URL in the system browser if requested
if flags.openBrowser {
browser.Open(ps1URL)
}
// Check if terminal UI should be enabled
// Disable termui if the flag is explicitly set to false or if we detect no PTY is available
if !term.IsTerminal(int(os.Stdin.Fd())) {
flags.termUI = false
}
// Create a variable for terminal UI
var s *termui.TermUI
// Create the termui instance only if needed
if flags.termUI {
s = termui.New(agent, ps1URL)
}
// Start skaband connection loop if needed
if flags.skabandAddr != "" {
connectFn := func(connected bool) {
if flags.verbose {
if connected {
if s != nil {
s.AppendSystemMessage("skaband connected")
}
} else {
if s != nil {
s.AppendSystemMessage("skaband disconnected")
}
}
}
}
if agentConfig.SkabandClient != nil {
go agentConfig.SkabandClient.DialAndServeLoop(ctx, flags.sessionID, srv, connectFn)
}
}
// Handle one-shot mode or mode without terminal UI
if flags.oneShot || s == nil {
it := agent.NewIterator(ctx, 0)
for {
m := it.Next()
if m == nil {
return nil
}
if m.Content != "" {
fmt.Printf("[%d] 💬 %s %s: %s\n", m.Idx, m.Timestamp.Format("15:04:05"), m.Type, m.Content)
}
if m.EndOfTurn && m.ParentConversationID == nil {
fmt.Printf("Total cost: $%0.2f\n", agent.TotalUsage().TotalCostUSD)
if flags.oneShot {
return nil
}
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
}
}
if s == nil {
panic("Should have exited above.")
}
// Run the terminal UI
defer func() {
r := recover()
if err := s.RestoreOldState(); err != nil {
fmt.Fprintf(os.Stderr, "couldn't restore old terminal state: %s\n", err)
}
if r != nil {
panic(r)
}
}()
if err := s.Run(ctx); err != nil {
return err
}
return nil
}
// setupLogging configures the logging system based on command-line flags.
// Returns the slog handler and optionally a log file (which should be closed by the caller).
func setupLogging(termui, verbose, unsafe bool) (slog.Handler, *os.File, error) {
var slogHandler slog.Handler
var logFile *os.File
var err error
if verbose && !termui {
// Log to stderr
slogHandler = slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})
return slogHandler, nil, nil
}
// Log to a file
logFile, err = os.CreateTemp("", "sketch-cli-log-*")
if err != nil {
return nil, nil, fmt.Errorf("cannot create log file: %v", err)
}
if unsafe {
fmt.Printf("structured logs: %v\n", logFile.Name())
}
slogHandler = slog.NewJSONHandler(logFile, &slog.HandlerOptions{Level: slog.LevelDebug})
slogHandler = skribe.AttrsWrap(slogHandler)
return slogHandler, logFile, nil
}
func getHostname() string {
hostname, err := os.Hostname()
if err != nil {
return "unknown"
}
return hostname
}
func defaultGitUsername() string {
out, err := exec.Command("git", "config", "user.name").CombinedOutput()
if err != nil {
return "Sketch🕴️" // TODO: what should this be?
}
return strings.TrimSpace(string(out))
}
func defaultGitEmail() string {
out, err := exec.Command("git", "config", "user.email").CombinedOutput()
if err != nil {
return "skallywag@sketch.dev" // TODO: what should this be?
}
return strings.TrimSpace(string(out))
}
// selectLLMService creates an LLM service based on the specified model name.
// If modelName is empty or "claude", it uses the Anthropic service.
// If modelName is "gemini", it uses the Gemini service.
// Otherwise, it tries to use the OpenAI service with the specified model.
// Returns an error if the model name is not recognized or if required configuration is missing.
func selectLLMService(client *http.Client, modelName string, modelURL, apiKey string) (llm.Service, error) {
if modelName == "" || modelName == "claude" {
if apiKey == "" {
return nil, fmt.Errorf("missing ANTHROPIC_API_KEY")
}
return &ant.Service{
HTTPC: client,
URL: modelURL,
APIKey: apiKey,
}, nil
}
if modelName == "gemini" {
if apiKey == "" {
return nil, fmt.Errorf("missing %s", gem.GeminiAPIKeyEnv)
}
return &gem.Service{
HTTPC: client,
URL: modelURL,
Model: gem.DefaultModel,
APIKey: apiKey,
}, nil
}
model := oai.ModelByUserName(modelName)
if model == nil {
return nil, fmt.Errorf("unknown model '%s', use -list-models to see available models", modelName)
}
// Verify we have an API key, if necessary.
apiKey = os.Getenv(model.APIKeyEnv)
if model.APIKeyEnv != "" && apiKey == "" {
return nil, fmt.Errorf("missing API key for %s model, set %s environment variable", model.UserName, model.APIKeyEnv)
}
return &oai.Service{
HTTPC: client,
Model: *model,
APIKey: apiKey,
}, nil
}
// dumpDistFilesystem dumps the embedded /dist/ filesystem to the specified directory
func dumpDistFilesystem(outputDir string) error {
// Build the embedded filesystem
distFS, err := webui.Build()
if err != nil {
return fmt.Errorf("failed to build embedded filesystem: %w", err)
}
// Create the output directory
if err := os.MkdirAll(outputDir, 0o755); err != nil {
return fmt.Errorf("failed to create output directory %q: %w", outputDir, err)
}
// Walk through the filesystem and copy all files
err = fs.WalkDir(distFS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
outputPath := filepath.Join(outputDir, path)
if d.IsDir() {
// Create directory
if err := os.MkdirAll(outputPath, 0o755); err != nil {
return fmt.Errorf("failed to create directory %q: %w", outputPath, err)
}
return nil
}
// Copy file
src, err := distFS.Open(path)
if err != nil {
return fmt.Errorf("failed to open source file %q: %w", path, err)
}
defer src.Close()
dst, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create destination file %q: %w", outputPath, err)
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
return fmt.Errorf("failed to copy file %q: %w", path, err)
}
return nil
})
if err != nil {
return fmt.Errorf("failed to dump filesystem: %w", err)
}
fmt.Printf("Successfully dumped embedded /dist/ filesystem to %q\n", outputDir)
return nil
}