diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-03-16 20:43:28 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-03-16 20:43:28 +0000 |
| commit | 7f920ca63af5329c19a0e5a879c649c594beea35 (patch) | |
| tree | 803150e7c895d3232bad35c729aad647aaa54348 /internal/cli | |
| parent | 072652f617653dce74368cedb42b88189e5014fb (diff) | |
feat: add web push notifications and file drop
Web Push:
- WebPushNotifier with VAPID auth; urgency mapped to event type
(BLOCKED=urgent, FAILED=high, COMPLETED=low)
- Auto-generates VAPID keys on first serve, persists to config file
- push_subscriptions table in SQLite (upsert by endpoint)
- GET /api/push/vapid-key, POST/DELETE /api/push/subscribe endpoints
- Service worker (sw.js) handles push events and notification clicks
- Notification bell button in web UI; subscribes on click
File Drop:
- GET /api/drops, GET /api/drops/{filename}, POST /api/drops
- Persistent ~/.claudomator/drops/ directory
- CLAUDOMATOR_DROP_DIR env var passed to agent subprocesses
- Drops tab (📁) in web UI with file listing and download links
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/cli')
| -rw-r--r-- | internal/cli/root.go | 1 | ||||
| -rw-r--r-- | internal/cli/serve.go | 88 |
2 files changed, 86 insertions, 3 deletions
diff --git a/internal/cli/root.go b/internal/cli/root.go index 7c4f2ff..5c6184e 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -60,6 +60,7 @@ func NewRootCmd() *cobra.Command { } cfg.DBPath = filepath.Join(cfg.DataDir, "claudomator.db") cfg.LogDir = filepath.Join(cfg.DataDir, "executions") + cfg.DropsDir = filepath.Join(cfg.DataDir, "drops") return nil } diff --git a/internal/cli/serve.go b/internal/cli/serve.go index 94f0c5d..1753a64 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -1,6 +1,7 @@ package cli import ( + "bytes" "context" "fmt" "net/http" @@ -10,6 +11,7 @@ import ( "syscall" "time" + "github.com/BurntSushi/toml" "github.com/thepeterstone/claudomator/internal/api" "github.com/thepeterstone/claudomator/internal/executor" "github.com/thepeterstone/claudomator/internal/notify" @@ -44,6 +46,23 @@ func serve(addr string) error { return fmt.Errorf("creating dirs: %w", err) } + // Auto-generate VAPID keys if not configured. + if cfg.VAPIDPublicKey == "" || cfg.VAPIDPrivateKey == "" { + pub, priv, err := notify.GenerateVAPIDKeys() + if err != nil { + return fmt.Errorf("generating VAPID keys: %w", err) + } + cfg.VAPIDPublicKey = pub + cfg.VAPIDPrivateKey = priv + // Write new keys back to config file. + if cfgFile != "" { + if err := saveVAPIDToConfig(cfgFile, pub, priv); err != nil { + // Non-fatal: log but continue. + fmt.Fprintf(os.Stderr, "warning: failed to persist VAPID keys to %s: %v\n", cfgFile, err) + } + } + } + store, err := storage.Open(cfg.DBPath) if err != nil { return fmt.Errorf("opening db: %w", err) @@ -56,22 +75,24 @@ func serve(addr string) error { if len(addr) > 0 && addr[0] != ':' { apiURL = "http://" + addr } - + runners := map[string]executor.Runner{ "claude": &executor.ClaudeRunner{ BinaryPath: cfg.ClaudeBinaryPath, Logger: logger, LogDir: cfg.LogDir, APIURL: apiURL, + DropsDir: cfg.DropsDir, }, "gemini": &executor.GeminiRunner{ BinaryPath: cfg.GeminiBinaryPath, Logger: logger, LogDir: cfg.LogDir, APIURL: apiURL, + DropsDir: cfg.DropsDir, }, } - + pool := executor.NewPool(cfg.MaxConcurrent, runners, store, logger) if cfg.GeminiBinaryPath != "" { pool.Classifier = &executor.Classifier{GeminiBinaryPath: cfg.GeminiBinaryPath} @@ -81,9 +102,26 @@ func serve(addr string) error { 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 != "" { - srv.SetNotifier(notify.NewWebhookNotifier(cfg.WebhookURL, logger)) + 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) } @@ -125,3 +163,47 @@ func serve(addr string) error { } return nil } + +// saveVAPIDToConfig appends VAPID key assignments to the config file. +// It reads the existing file (if any), then writes a complete TOML file with +// the new keys merged in. Uses toml encoder for correctness. +func saveVAPIDToConfig(path, pub, priv string) error { + existing := cfg // already loaded + + // Marshal the full config back including the new VAPID keys. + // We use a struct alias to only encode fields we want persisted. + type persistedConfig struct { + DataDir string `toml:"data_dir,omitempty"` + ClaudeBinaryPath string `toml:"claude_binary_path,omitempty"` + GeminiBinaryPath string `toml:"gemini_binary_path,omitempty"` + MaxConcurrent int `toml:"max_concurrent,omitempty"` + DefaultTimeout string `toml:"default_timeout,omitempty"` + ServerAddr string `toml:"server_addr,omitempty"` + WebhookURL string `toml:"webhook_url,omitempty"` + WorkspaceRoot string `toml:"workspace_root,omitempty"` + WebhookSecret string `toml:"webhook_secret,omitempty"` + VAPIDPublicKey string `toml:"vapid_public_key,omitempty"` + VAPIDPrivateKey string `toml:"vapid_private_key,omitempty"` + VAPIDEmail string `toml:"vapid_email,omitempty"` + } + pc := persistedConfig{ + DataDir: existing.DataDir, + ClaudeBinaryPath: existing.ClaudeBinaryPath, + GeminiBinaryPath: existing.GeminiBinaryPath, + MaxConcurrent: existing.MaxConcurrent, + DefaultTimeout: existing.DefaultTimeout, + ServerAddr: existing.ServerAddr, + WebhookURL: existing.WebhookURL, + WorkspaceRoot: existing.WorkspaceRoot, + WebhookSecret: existing.WebhookSecret, + VAPIDPublicKey: pub, + VAPIDPrivateKey: priv, + VAPIDEmail: existing.VAPIDEmail, + } + + var buf bytes.Buffer + if err := toml.NewEncoder(&buf).Encode(pc); err != nil { + return fmt.Errorf("encoding config: %w", err) + } + return os.WriteFile(path, buf.Bytes(), 0600) +} |
