package cli import ( "context" "fmt" "net/http" "os" "os/signal" "path/filepath" "syscall" "time" "github.com/thepeterstone/claudomator/internal/api" "github.com/thepeterstone/claudomator/internal/executor" "github.com/thepeterstone/claudomator/internal/notify" "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/version" "github.com/spf13/cobra" ) func newServeCmd() *cobra.Command { var addr string var workspaceRoot string cmd := &cobra.Command{ Use: "serve", Short: "Start the Claudomator API server", RunE: func(cmd *cobra.Command, args []string) error { if cmd.Flags().Changed("workspace-root") { cfg.WorkspaceRoot = workspaceRoot } return serve(addr) }, } cmd.Flags().StringVar(&addr, "addr", ":8484", "listen address") cmd.Flags().StringVar(&workspaceRoot, "workspace-root", "/workspace", "root directory for listing workspaces") cmd.Flags().StringVar(&cfg.ClaudeImage, "claude-image", cfg.ClaudeImage, "docker image for claude agents") cmd.Flags().StringVar(&cfg.GeminiImage, "gemini-image", cfg.GeminiImage, "docker image for gemini agents") return cmd } func serve(addr string) error { if err := cfg.EnsureDirs(); err != nil { return fmt.Errorf("creating dirs: %w", err) } store, err := storage.Open(cfg.DBPath) if err != nil { return fmt.Errorf("opening db: %w", err) } defer store.Close() // Load VAPID keys from DB; generate and persist if missing. if cfg.VAPIDPublicKey == "" || cfg.VAPIDPrivateKey == "" { pub, _ := store.GetSetting("vapid_public_key") priv, _ := store.GetSetting("vapid_private_key") if pub == "" || priv == "" || !notify.ValidateVAPIDPublicKey(pub) { pub, priv, err = notify.GenerateVAPIDKeys() if err != nil { return fmt.Errorf("generating VAPID keys: %w", err) } _ = store.SetSetting("vapid_public_key", pub) _ = store.SetSetting("vapid_private_key", priv) } cfg.VAPIDPublicKey = pub cfg.VAPIDPrivateKey = priv } logger := newLogger(verbose) apiURL := "http://localhost" + addr if len(addr) > 0 && addr[0] != ':' { apiURL = "http://" + addr } // Use configured credentials dir; sync-credentials keeps this populated. claudeConfigDir := cfg.ClaudeConfigDir repoDir, _ := os.Getwd() runners := map[string]executor.Runner{ // ContainerRunner: binaries are resolved via PATH inside the container image, // so ClaudeBinary/GeminiBinary are left empty (host paths would not exist inside). "claude": &executor.ContainerRunner{ Image: cfg.ClaudeImage, Logger: logger, LogDir: cfg.LogDir, APIURL: apiURL, DropsDir: cfg.DropsDir, SSHAuthSock: cfg.SSHAuthSock, ClaudeConfigDir: claudeConfigDir, CredentialSyncCmd: filepath.Join(repoDir, "scripts", "sync-credentials"), }, "gemini": &executor.ContainerRunner{ Image: cfg.GeminiImage, Logger: logger, LogDir: cfg.LogDir, APIURL: apiURL, DropsDir: cfg.DropsDir, SSHAuthSock: cfg.SSHAuthSock, ClaudeConfigDir: claudeConfigDir, CredentialSyncCmd: filepath.Join(repoDir, "scripts", "sync-credentials"), }, "container": &executor.ContainerRunner{ Image: "claudomator-agent:latest", Logger: logger, LogDir: cfg.LogDir, APIURL: apiURL, DropsDir: cfg.DropsDir, SSHAuthSock: cfg.SSHAuthSock, ClaudeConfigDir: claudeConfigDir, CredentialSyncCmd: filepath.Join(repoDir, "scripts", "sync-credentials"), }, } pool := executor.NewPool(cfg.MaxConcurrent, runners, store, logger) if cfg.GeminiBinaryPath != "" { pool.Classifier = &executor.Classifier{GeminiBinaryPath: cfg.GeminiBinaryPath} } pool.RecoverStaleRunning(context.Background()) pool.RecoverStaleQueued(context.Background()) pool.RecoverStaleBlocked() srv := api.NewServer(store, pool, logger, cfg.ClaudeBinaryPath, cfg.GeminiBinaryPath) // Configure notifiers: combine webhook (if set) with web push. notifiers := []notify.Notifier{} if cfg.WebhookURL != "" { notifiers = append(notifiers, notify.NewWebhookNotifier(cfg.WebhookURL, logger)) } webPushNotifier := ¬ify.WebPushNotifier{ Store: store, VAPIDPublicKey: cfg.VAPIDPublicKey, VAPIDPrivateKey: cfg.VAPIDPrivateKey, VAPIDEmail: cfg.VAPIDEmail, Logger: logger, } notifiers = append(notifiers, webPushNotifier) srv.SetNotifier(notify.NewMultiNotifier(logger, notifiers...)) srv.SetVAPIDConfig(cfg.VAPIDPublicKey, cfg.VAPIDPrivateKey, cfg.VAPIDEmail) srv.SetPushStore(store) srv.SetDropsDir(cfg.DropsDir) if cfg.WorkspaceRoot != "" { srv.SetWorkspaceRoot(cfg.WorkspaceRoot) } srv.SetGitHubWebhookConfig(cfg.WebhookSecret, cfg.Projects) // Register scripts. wd, _ := os.Getwd() srv.SetScripts(api.ScriptRegistry{ "start-next-task": filepath.Join(wd, "scripts", "start-next-task"), "deploy": filepath.Join(wd, "scripts", "deploy"), }) srv.StartHub() httpSrv := &http.Server{ Addr: addr, Handler: srv.Handler(), } // Graceful shutdown. ctx, cancel := context.WithCancel(context.Background()) defer cancel() sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) go func() { <-sigCh logger.Info("shutting down server...") shutdownCtx, shutdownCancel := context.WithTimeout(ctx, 5*time.Second) defer shutdownCancel() if err := httpSrv.Shutdown(shutdownCtx); err != nil { logger.Warn("shutdown error", "err", err) } }() fmt.Printf("Claudomator %s listening on %s\n", version.Version(), addr) if err := httpSrv.ListenAndServe(); err != http.ErrServerClosed { return err } return nil }