summaryrefslogtreecommitdiff
path: root/internal/config/config.go
blob: 6f6b958b86980c395b14f4452df9ca3d931e2db4 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
package config

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"time"

	"github.com/BurntSushi/toml"
)

// Project represents a named workspace project used for webhook routing.
type Project struct {
	Name string `toml:"name"`
	Dir  string `toml:"dir"`
}

// LocalModel configures an OpenAI-compatible local LLM endpoint used for
// internal helpers (classifier, elaboration, future summarization) and as
// the backend for the "local" runner. If Endpoint is empty, the LocalRunner
// is not registered and the classifier falls back to the Gemini CLI.
//
// PreferForElaborate gates whether the API server's elaboration handler
// uses this client. It defaults to true when Endpoint is set; users with a
// slow or low-quality local model can disable it.
type LocalModel struct {
	Endpoint           string  `toml:"endpoint"`              // e.g. "http://localhost:11434/v1"
	Model              string  `toml:"model"`                 // e.g. "llama3.1:8b"
	TimeoutSeconds     int     `toml:"timeout_seconds"`       // default 60
	DefaultTemperature float64 `toml:"default_temperature"`   // default 0.2
	APIKey             string  `toml:"api_key"`               // optional bearer token
	PreferForElaborate *bool   `toml:"prefer_for_elaborate"`  // pointer so default-true survives parse
}

// UseForElaborate returns true when elaboration should try this local model
// before falling back to Claude/Gemini. Default is true when Endpoint is set.
func (m LocalModel) UseForElaborate() bool {
	if m.Endpoint == "" {
		return false
	}
	if m.PreferForElaborate == nil {
		return true
	}
	return *m.PreferForElaborate
}

type Config struct {
	DataDir          string        `toml:"data_dir"`
	DBPath           string        `toml:"-"`
	LogDir           string        `toml:"-"`
	DropsDir         string        `toml:"-"`
	SSHAuthSock      string        `toml:"ssh_auth_sock"`
	ClaudeBinaryPath string        `toml:"claude_binary_path"`
	GeminiBinaryPath string        `toml:"gemini_binary_path"`
	ClaudeImage      string        `toml:"claude_image"`
	GeminiImage      string        `toml:"gemini_image"`
	MaxConcurrent    int           `toml:"max_concurrent"`
	ShutdownTimeout  time.Duration `toml:"shutdown_timeout"`
	DefaultTimeout   string        `toml:"default_timeout"`
	ServerAddr       string        `toml:"server_addr"`
	WebhookURL       string        `toml:"webhook_url"`
	WorkspaceRoot    string        `toml:"workspace_root"`
	WebhookSecret    string        `toml:"webhook_secret"`
	APIToken         string        `toml:"api_token"` // shared bearer for web UI + chatbot MCP; empty disables both auth and the chatbot MCP endpoint
	Projects         []Project     `toml:"projects"`
	VAPIDPublicKey   string        `toml:"vapid_public_key"`
	VAPIDPrivateKey  string        `toml:"vapid_private_key"`
	VAPIDEmail       string        `toml:"vapid_email"`
	ClaudeConfigDir  string        `toml:"claude_config_dir"`
	LocalModel       LocalModel    `toml:"local_model"`
	Budget           BudgetConfig  `toml:"budget"`
	// ExternalBindAllowed must be explicitly true to bind a non-loopback address.
	// Default external access should go through a reverse proxy (Tailscale /
	// Cloudflare Access / Caddy); binding the server directly is a footgun.
	ExternalBindAllowed bool `toml:"external_bind_allowed"`
}

// BudgetConfig configures rolling per-provider spend caps. With no providers
// set, budget gating is disabled (nothing is blocked) — there is intentionally
// no default cap; the user must opt in by configuring limits.
type BudgetConfig struct {
	// Window is the rolling lookback duration (e.g. "5h"); defaults to 5h.
	Window string `toml:"window"`
	// Provider5hUSD maps a provider (claude/gemini) to its USD cap within Window.
	Provider5hUSD map[string]float64 `toml:"provider_5h_usd"`
}

func Default() (*Config, error) {
	home, err := os.UserHomeDir()
	if err != nil {
		return nil, fmt.Errorf("cannot determine home directory: %w", err)
	}
	if home == "" {
		return nil, errors.New("cannot determine home directory: HOME is empty")
	}
	dataDir := filepath.Join(home, ".claudomator")
	return &Config{
		DataDir:          dataDir,
		DBPath:           filepath.Join(dataDir, "claudomator.db"),
		LogDir:           filepath.Join(dataDir, "executions"),
		DropsDir:         filepath.Join(dataDir, "drops"),
		SSHAuthSock:      os.Getenv("SSH_AUTH_SOCK"),
		ClaudeBinaryPath: "claude",
		GeminiBinaryPath: "gemini",
		ClaudeImage:      "claudomator-agent:latest",
		GeminiImage:      "claudomator-agent:latest",
		MaxConcurrent:    3,
		DefaultTimeout:   "15m",
		ServerAddr:       "127.0.0.1:8484",
		WorkspaceRoot:    "/workspace",
		ClaudeConfigDir:  "/workspace/claudomator/credentials/claude",
	}, nil
}

// LoadFile loads a TOML config file on top of the defaults.
// Fields not present in the file retain their default values.
func LoadFile(path string) (*Config, error) {
	cfg, err := Default()
	if err != nil {
		return nil, err
	}
	if _, err := toml.DecodeFile(path, cfg); err != nil {
		return nil, fmt.Errorf("loading config file %q: %w", path, err)
	}
	return cfg, nil
}

// EnsureDirs creates the data directory structure.
func (c *Config) EnsureDirs() error {
	for _, dir := range []string{c.DataDir, c.LogDir, c.DropsDir} {
		if err := os.MkdirAll(dir, 0700); err != nil {
			return err
		}
	}
	return nil
}