Detailed changes
@@ -545,7 +545,7 @@ config:
}
```
-## Disabling Provider Auto-Updates
+## Provider Auto-Updates
By default, Crush automatically checks for the latest and greatest list of
providers and models from [Catwalk](https://github.com/charmbracelet/catwalk),
@@ -553,6 +553,8 @@ the open source Crush provider database. This means that when new providers and
models are available, or when model metadata changes, Crush automatically
updates your local configuration.
+### Disabling automatic provider updates
+
For those with restricted internet access, or those who prefer to work in
air-gapped environments, this might not be want you want, and this feature can
be disabled.
@@ -597,6 +599,36 @@ crush update-providers embedded
crush update-providers --help
```
+## Metrics
+
+Crush records pseudonymous usage metrics (tied to a device-specific hash),
+which maintainers rely on to inform development and support priorities. The
+metrics include solely usage metadata; prompts and responses are NEVER
+collected.
+
+Details on exactly whatβs collected are in the source code ([here](https://github.com/charmbracelet/crush/tree/main/internal/event)
+and [here](https://github.com/charmbracelet/crush/blob/main/internal/llm/agent/event.go)).
+
+You can opt out of metrics collection at any time by setting the environment
+variable by setting the following in your environment:
+
+```bash
+export CRUSH_DISABLE_METRICS=1
+```
+
+Or by setting the following in your config:
+
+```json
+{
+ "options": {
+ "disable_metrics": true
+ }
+}
+```
+
+Crush also respects the [`DO_NOT_TRACK`](https://consoledonottrack.com)
+convention which can be enabled via `export DO_NOT_TRACK=1`.
+
## A Note on Claude Max and GitHub Copilot
Crush only supports model providers through official, compliant APIs. We do not
@@ -81,6 +81,7 @@ require (
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/denisbrodbeck/machineid v1.0.1
github.com/disintegration/gift v1.1.2 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -95,6 +96,7 @@ require (
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
+ github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.0.9 // indirect
@@ -114,6 +116,7 @@ require (
github.com/ncruces/julianday v1.0.0 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/posthog/posthog-go v1.6.10
github.com/rivo/uniseg v0.4.7
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
@@ -118,6 +118,8 @@ github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ=
+github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI=
github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
@@ -162,6 +164,8 @@ github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -233,6 +237,8 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjL
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/posthog/posthog-go v1.6.10 h1:OA6bkiUg89rI7f5cSXbcrH5+wLinyS6hHplnD92Pu/M=
+github.com/posthog/posthog-go v1.6.10/go.mod h1:LcC1Nu4AgvV22EndTtrMXTy+7RGVC0MhChSw7Qk5XkY=
github.com/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjGng=
github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
github.com/qjebbs/go-jsons v1.0.0-alpha.4 h1:Qsb4ohRUHQODIUAsJKdKJ/SIDbsO7oGOzsfy+h1yQZs=
@@ -7,11 +7,13 @@ import (
"log/slog"
"os"
"path/filepath"
+ "strconv"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/crush/internal/app"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/db"
+ "github.com/charmbracelet/crush/internal/event"
"github.com/charmbracelet/crush/internal/tui"
"github.com/charmbracelet/crush/internal/version"
"github.com/charmbracelet/fang"
@@ -66,6 +68,8 @@ crush -y
}
defer app.Shutdown()
+ event.AppInitialized()
+
// Set up the TUI.
program := tea.NewProgram(
tui.New(app),
@@ -78,11 +82,15 @@ crush -y
go app.Subscribe(program)
if _, err := program.Run(); err != nil {
+ event.Error(err)
slog.Error("TUI run error", "error", err)
return fmt.Errorf("TUI error: %v", err)
}
return nil
},
+ PostRun: func(cmd *cobra.Command, args []string) {
+ event.AppExited()
+ },
}
func Execute() {
@@ -135,9 +143,26 @@ func setupApp(cmd *cobra.Command) (*app.App, error) {
return nil, err
}
+ if shouldEnableMetrics() {
+ event.Init()
+ }
+
return appInstance, nil
}
+func shouldEnableMetrics() bool {
+ if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
+ return false
+ }
+ if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
+ return false
+ }
+ if config.Get().Options.DisableMetrics {
+ return false
+ }
+ return true
+}
+
func MaybePrependStdin(prompt string) (string, error) {
if term.IsTerminal(os.Stdin.Fd()) {
return prompt, nil
@@ -153,6 +153,7 @@ type Options struct {
DisabledTools []string `json:"disabled_tools" jsonschema:"description=Tools to disable"`
DisableProviderAutoUpdate bool `json:"disable_provider_auto_update,omitempty" jsonschema:"description=Disable providers auto-update,default=false"`
Attribution *Attribution `json:"attribution,omitempty" jsonschema:"description=Attribution settings for generated content"`
+ DisableMetrics bool `json:"disable_metrics,omitempty" jsonschema:"description=Disable sending metrics,default=false"`
}
type MCPs map[string]MCPConfig
@@ -0,0 +1,59 @@
+package event
+
+import (
+ "time"
+)
+
+var appStartTime time.Time
+
+func AppInitialized() {
+ appStartTime = time.Now()
+ send("app initialized")
+}
+
+func AppExited() {
+ duration := time.Since(appStartTime).Truncate(time.Second)
+ send(
+ "app exited",
+ "app duration pretty", duration.String(),
+ "app duration in seconds", int64(duration.Seconds()),
+ )
+ Flush()
+}
+
+func SessionCreated() {
+ send("session created")
+}
+
+func SessionDeleted() {
+ send("session deleted")
+}
+
+func SessionSwitched() {
+ send("session switched")
+}
+
+func FilePickerOpened() {
+ send("filepicker opened")
+}
+
+func PromptSent(props ...any) {
+ send(
+ "prompt sent",
+ props...,
+ )
+}
+
+func PromptResponded(props ...any) {
+ send(
+ "prompt responded",
+ props...,
+ )
+}
+
+func TokensUsed(props ...any) {
+ send(
+ "tokens used",
+ props...,
+ )
+}
@@ -0,0 +1,114 @@
+package event
+
+import (
+ "fmt"
+ "log/slog"
+ "os"
+ "reflect"
+ "runtime"
+
+ "github.com/charmbracelet/crush/internal/version"
+ "github.com/denisbrodbeck/machineid"
+ "github.com/posthog/posthog-go"
+)
+
+const (
+ endpoint = "https://data.charm.land"
+ key = "phc_4zt4VgDWLqbYnJYEwLRxFoaTL2noNrQij0C6E8k3I0V"
+)
+
+var (
+ client posthog.Client
+
+ baseProps = posthog.NewProperties().
+ Set("GOOS", runtime.GOOS).
+ Set("GOARCH", runtime.GOARCH).
+ Set("TERM", os.Getenv("TERM")).
+ Set("SHELL", os.Getenv("SHELL")).
+ Set("Version", version.Version).
+ Set("GoVersion", runtime.Version())
+)
+
+func Init() {
+ c, err := posthog.NewWithConfig(key, posthog.Config{
+ Endpoint: endpoint,
+ Logger: logger{},
+ })
+ if err != nil {
+ slog.Error("Failed to initialize PostHog client", "error", err)
+ }
+ client = c
+}
+
+// send logs an event to PostHog with the given event name and properties.
+func send(event string, props ...any) {
+ if client == nil {
+ return
+ }
+ err := client.Enqueue(posthog.Capture{
+ DistinctId: distinctId(),
+ Event: event,
+ Properties: pairsToProps(props...).Merge(baseProps),
+ })
+ if err != nil {
+ slog.Error("Failed to enqueue PostHog event", "event", event, "props", props, "error", err)
+ return
+ }
+}
+
+// Error logs an error event to PostHog with the error type and message.
+func Error(err any, props ...any) {
+ if client == nil {
+ return
+ }
+ // The PostHog Go client does not yet support sending exceptions.
+ // We're mimicking the behavior by sending the minimal info required
+ // for PostHog to recognize this as an exception event.
+ props = append(
+ []any{
+ "$exception_list",
+ []map[string]string{
+ {"type": reflect.TypeOf(err).String(), "value": fmt.Sprintf("%v", err)},
+ },
+ },
+ props...,
+ )
+ send("$exception", props...)
+}
+
+func Flush() {
+ if client == nil {
+ return
+ }
+ if err := client.Close(); err != nil {
+ slog.Error("Failed to flush PostHog events", "error", err)
+ }
+}
+
+func pairsToProps(props ...any) posthog.Properties {
+ p := posthog.NewProperties()
+
+ if !isEven(len(props)) {
+ slog.Error("Event properties must be provided as key-value pairs", "props", props)
+ return p
+ }
+
+ for i := 0; i < len(props); i += 2 {
+ key := props[i].(string)
+ value := props[i+1]
+ p = p.Set(key, value)
+ }
+ return p
+}
+
+func isEven(n int) bool {
+ return n%2 == 0
+}
+
+func distinctId() string {
+ id, err := machineid.ProtectedID("charm")
+ if err != nil {
+ return "crush-cli"
+ }
+ return id
+}
@@ -0,0 +1,27 @@
+package event
+
+import (
+ "log/slog"
+
+ "github.com/posthog/posthog-go"
+)
+
+var _ posthog.Logger = logger{}
+
+type logger struct{}
+
+func (logger) Debugf(format string, args ...any) {
+ slog.Debug(format, args...)
+}
+
+func (logger) Logf(format string, args ...any) {
+ slog.Info(format, args...)
+}
+
+func (logger) Warnf(format string, args ...any) {
+ slog.Warn(format, args...)
+}
+
+func (logger) Errorf(format string, args ...any) {
+ slog.Error(format, args...)
+}
@@ -12,6 +12,7 @@ import (
"github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/csync"
+ "github.com/charmbracelet/crush/internal/event"
"github.com/charmbracelet/crush/internal/history"
"github.com/charmbracelet/crush/internal/llm/prompt"
"github.com/charmbracelet/crush/internal/llm/provider"
@@ -25,12 +26,6 @@ import (
"github.com/charmbracelet/crush/internal/shell"
)
-// Common errors
-var (
- ErrRequestCancelled = errors.New("request canceled by user")
- ErrSessionBusy = errors.New("session is currently processing another request")
-)
-
type AgentEventType string
const (
@@ -66,10 +61,11 @@ type Service interface {
type agent struct {
*pubsub.Broker[AgentEvent]
- agentCfg config.Agent
- sessions session.Service
- messages message.Service
- mcpTools []McpTool
+ agentCfg config.Agent
+ sessions session.Service
+ messages message.Service
+ permissions permission.Service
+ mcpTools []McpTool
tools *csync.LazySlice[tools.BaseTool]
// We need this to be able to update it when model changes
@@ -237,6 +233,7 @@ func NewAgent(
activeRequests: csync.NewMap[string, context.CancelFunc](),
tools: csync.NewLazySlice(toolFn),
promptQueue: csync.NewMap[string, []string](),
+ permissions: permissions,
}, nil
}
@@ -365,8 +362,9 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac
}
genCtx, cancel := context.WithCancel(ctx)
-
a.activeRequests.Set(sessionID, cancel)
+ startTime := time.Now()
+
go func() {
slog.Debug("Request started", "sessionID", sessionID)
defer log.RecoverPanic("agent.Run", func() {
@@ -377,16 +375,24 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac
attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
}
result := a.processGeneration(genCtx, sessionID, content, attachmentParts)
- if result.Error != nil && !errors.Is(result.Error, ErrRequestCancelled) && !errors.Is(result.Error, context.Canceled) {
- slog.Error(result.Error.Error())
+ if result.Error != nil {
+ if isCancelledErr(result.Error) {
+ slog.Error("Request canceled", "sessionID", sessionID)
+ } else {
+ slog.Error("Request errored", "sessionID", sessionID, "error", result.Error.Error())
+ event.Error(result.Error)
+ }
+ } else {
+ slog.Debug("Request completed", "sessionID", sessionID)
}
- slog.Debug("Request completed", "sessionID", sessionID)
+ a.eventPromptResponded(sessionID, time.Since(startTime).Truncate(time.Second))
a.activeRequests.Del(sessionID)
cancel()
a.Publish(pubsub.CreatedEvent, result)
events <- result
close(events)
}()
+ a.eventPromptSent(sessionID)
return events, nil
}
@@ -726,13 +732,13 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
if err := a.messages.Update(ctx, *assistantMsg); err != nil {
return fmt.Errorf("failed to update message: %w", err)
}
- return a.TrackUsage(ctx, sessionID, a.Model(), event.Response.Usage)
+ return a.trackUsage(ctx, sessionID, a.Model(), event.Response.Usage)
}
return nil
}
-func (a *agent) TrackUsage(ctx context.Context, sessionID string, model catwalk.Model, usage provider.TokenUsage) error {
+func (a *agent) trackUsage(ctx context.Context, sessionID string, model catwalk.Model, usage provider.TokenUsage) error {
sess, err := a.sessions.Get(ctx, sessionID)
if err != nil {
return fmt.Errorf("failed to get session: %w", err)
@@ -743,6 +749,8 @@ func (a *agent) TrackUsage(ctx context.Context, sessionID string, model catwalk.
model.CostPer1MIn/1e6*float64(usage.InputTokens) +
model.CostPer1MOut/1e6*float64(usage.OutputTokens)
+ a.eventTokensUsed(sessionID, usage, cost)
+
sess.Cost += cost
sess.CompletionTokens = usage.OutputTokens + usage.CacheReadTokens
sess.PromptTokens = usage.InputTokens + usage.CacheCreationTokens
@@ -0,0 +1,15 @@
+package agent
+
+import (
+ "context"
+ "errors"
+)
+
+var (
+ ErrRequestCancelled = errors.New("request canceled by user")
+ ErrSessionBusy = errors.New("session is currently processing another request")
+)
+
+func isCancelledErr(err error) bool {
+ return errors.Is(err, context.Canceled) || errors.Is(err, ErrRequestCancelled)
+}
@@ -0,0 +1,53 @@
+package agent
+
+import (
+ "time"
+
+ "github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/crush/internal/event"
+ "github.com/charmbracelet/crush/internal/llm/provider"
+)
+
+func (a *agent) eventPromptSent(sessionID string) {
+ event.PromptSent(
+ a.eventCommon(sessionID)...,
+ )
+}
+
+func (a *agent) eventPromptResponded(sessionID string, duration time.Duration) {
+ event.PromptResponded(
+ append(
+ a.eventCommon(sessionID),
+ "prompt duration pretty", duration.String(),
+ "prompt duration in seconds", int64(duration.Seconds()),
+ )...,
+ )
+}
+
+func (a *agent) eventTokensUsed(sessionID string, usage provider.TokenUsage, cost float64) {
+ event.TokensUsed(
+ append(
+ a.eventCommon(sessionID),
+ "input tokens", usage.InputTokens,
+ "output tokens", usage.OutputTokens,
+ "cache read tokens", usage.CacheReadTokens,
+ "cache creation tokens", usage.CacheCreationTokens,
+ "total tokens", usage.InputTokens+usage.OutputTokens+usage.CacheReadTokens+usage.CacheCreationTokens,
+ "cost", cost,
+ )...,
+ )
+}
+
+func (a *agent) eventCommon(sessionID string) []any {
+ cfg := config.Get()
+ currentModel := cfg.Models[cfg.Agents["coder"].Model]
+
+ return []any{
+ "session id", sessionID,
+ "provider", currentModel.Provider,
+ "model", currentModel.Model,
+ "reasoning effort", currentModel.ReasoningEffort,
+ "thinking mode", currentModel.Think,
+ "yolo mode", a.permissions.SkipRequests(),
+ }
+}
@@ -9,6 +9,7 @@ import (
"sync/atomic"
"time"
+ "github.com/charmbracelet/crush/internal/event"
"gopkg.in/natefinch/lumberjack.v2"
)
@@ -48,6 +49,8 @@ func Initialized() bool {
func RecoverPanic(name string, cleanup func()) {
if r := recover(); r != nil {
+ event.Error(r, "panic", true, "name", name)
+
// Create a timestamped panic log file
timestamp := time.Now().Format("20060102-150405")
filename := fmt.Sprintf("crush-panic-%s-%s.log", name, timestamp)
@@ -5,6 +5,7 @@ import (
"database/sql"
"github.com/charmbracelet/crush/internal/db"
+ "github.com/charmbracelet/crush/internal/event"
"github.com/charmbracelet/crush/internal/pubsub"
"github.com/google/uuid"
)
@@ -48,6 +49,7 @@ func (s *service) Create(ctx context.Context, title string) (Session, error) {
}
session := s.fromDBItem(dbSession)
s.Publish(pubsub.CreatedEvent, session)
+ event.SessionCreated()
return session, nil
}
@@ -89,6 +91,7 @@ func (s *service) Delete(ctx context.Context, id string) error {
return err
}
s.Publish(pubsub.DeletedEvent, session)
+ event.SessionDeleted()
return nil
}
@@ -4,6 +4,7 @@ import (
"github.com/charmbracelet/bubbles/v2/help"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/crush/internal/event"
"github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/tui/components/chat"
"github.com/charmbracelet/crush/internal/tui/components/core"
@@ -99,6 +100,7 @@ func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
selectedItem := s.sessionsList.SelectedItem()
if selectedItem != nil {
selected := *selectedItem
+ event.SessionSwitched()
return s, tea.Sequence(
util.CmdHandler(dialogs.CloseDialogMsg{}),
util.CmdHandler(
@@ -10,6 +10,7 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/crush/internal/app"
"github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/crush/internal/event"
"github.com/charmbracelet/crush/internal/llm/agent"
"github.com/charmbracelet/crush/internal/permission"
"github.com/charmbracelet/crush/internal/pubsub"
@@ -196,6 +197,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.app.CoderAgent.IsBusy() {
return a, util.ReportWarn("Agent is busy, please wait...")
}
+
config.Get().UpdatePreferredModel(msg.ModelType, msg.Model)
// Update the agent with the new model/provider configuration
@@ -211,6 +213,8 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// File Picker
case commands.OpenFilePickerMsg:
+ event.FilePickerOpened()
+
if a.dialog.ActiveDialogID() == filepicker.FilePickerID {
// If the commands dialog is already open, close it
return a, util.CmdHandler(dialogs.CloseDialogMsg{})
@@ -10,11 +10,13 @@ import (
_ "github.com/joho/godotenv/autoload" // automatically load .env files
"github.com/charmbracelet/crush/internal/cmd"
+ "github.com/charmbracelet/crush/internal/event"
"github.com/charmbracelet/crush/internal/log"
)
func main() {
defer log.RecoverPanic("main", func() {
+ event.Flush()
slog.Error("Application terminated due to unhandled panic")
})
@@ -320,6 +320,11 @@
"attribution": {
"$ref": "#/$defs/Attribution",
"description": "Attribution settings for generated content"
+ },
+ "disable_metrics": {
+ "type": "boolean",
+ "description": "Disable sending metrics",
+ "default": false
}
},
"additionalProperties": false,